Zero-dependency markdown notes, synced to Koofr or any standard WebDAV endpoint.
qk is a simple bash script that pulls a
markdown note from webDAV cloud storage, opens it in
$EDITOR, and pushes it back if you changed
anything. No git, no daemons, no heavy apps to install -
just curl and a local folder.
qk.sh above and make it
executable:
# move it somewhere on your PATH
mv qk.sh ~/bin/qk
chmod +x ~/bin/qk
$ qk
No config found at ~/qk/qk.conf - let's set one up.
Select your provider:
1) Koofr
2) Custom WebDAV Server
Choice [1-2]: 2
WebDAV Server URL (e.g., https://mycloud.xyz/dav): https://mycloud.xyz/dav/
Username: your-username
App Password (input hidden):
Remote notes folder inside WebDAV [/notes]: /personal/notes
Save this config for future runs? [Y/n] Y
Your configuration is saved at
~/qk/qk.conf with restrictive
chmod 600 permissions. To clear or alter your
configuration later, run qk -c to create a new
qk.conf file.
qk [options] [note]
| Command | What it does |
|---|---|
qk |
Open (or create) default note.md
|
qk ideas |
Open ideas.md - pulls, edits,
pushes if changed
|
qk -t |
Open today's note, YYYY-MM-DD.md
|
qk -l |
List local notes |
qk -g pattern |
Grep across local notes |
qk -d ideas |
Delete a note, local and remote (with confirmation) |
qk -r old new |
Rename a note, local and remote (WebDAV
MOVE)
|
qk -s |
Sync (pull) all remote notes down to local directory |
qk -b |
Backup all local notes to
~/qk-YYYY-MM-DD.tar
|
qk -c |
Clear saved credentials and reconfigure |
qk -h |
Show help |
The full script - copy it directly if you'd rather not download the file.
#!/usr/bin/env bash
# qk - Quick Notes
# A zero-dependency CLI tool to sync markdown notes via a WebDAV endpoint.
# Author: Barney Matthews. License: MIT
NOTES_DIR="$HOME/qk"
CONFIG_FILE="$NOTES_DIR/qk.conf"
mkdir -p "$NOTES_DIR"
for cmd in curl grep sed awk find tar; do
command -v "$cmd" >/dev/null 2>&1 || { echo "Error: '$cmd' is required but not installed." >&2; exit 1; }
done
# --- Config ---
if [ -f "$CONFIG_FILE" ]; then
chmod 600 "$CONFIG_FILE"
while IFS='=' read -r key value; do
key=$(echo "$key" | tr -d ' ')
[ -z "$key" ] && continue
case "$key" in \#*) continue ;; esac
value=$(echo "$value" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' -e 's/[[:space:]]*#.*//')
case "$value" in \'*\'|\"*\" ) value=$(echo "$value" | sed 's/^.\(.*\).$/\1/') ;; esac
case "$key" in
QK_USER) QK_USER="$value" ;;
QK_PASS) QK_PASS="$value" ;;
QK_PATH) QK_PATH="$value" ;;
QK_URL) QK_URL="$value" ;;
esac
done < "$CONFIG_FILE"
fi
# --- First-run / reconfigure setup ---
if [ -z "$QK_USER" ] || [ -z "$QK_PASS" ] || [ -z "$QK_URL" ]; then
echo "No config found at $CONFIG_FILE - let's set one up."
echo "Select your provider:"
echo "1) Koofr"
echo "2) Custom WebDAV Server"
printf "Choice [1-2]: "
read -r provider_choice
case "$provider_choice" in
2)
# Custom WebDAV Setup
printf "WebDAV Server URL (e.g., https://example.com/remote.php/dav/files/user/): "
read -r QK_URL
printf "Username: "
read -r QK_USER
printf "App Password (input hidden): "
stty -echo; read -r QK_PASS; stty echo; echo
printf "Remote notes folder inside WebDAV [/notes]: "
read -r QK_PATH
;;
*)
# Default Koofr Setup
QK_URL="https://app.koofr.net/dav/Koofr"
printf "Koofr email/username: "
read -r QK_USER
printf "Koofr app password (input hidden): "
stty -echo; read -r QK_PASS; stty echo; echo
printf "Remote notes folder [/notes]: "
read -r QK_PATH
;;
esac
QK_PATH="${QK_PATH:-/notes}"
printf "Save this config for future runs? [Y/n] "
read -r save
case "$save" in
[Nn]|[Nn][Oo]) echo "Using credentials for this session only." ;;
*)
umask 077
cat > "$CONFIG_FILE" <<EOF
QK_URL=$QK_URL
QK_USER=$QK_USER
QK_PASS=$QK_PASS
QK_PATH=$QK_PATH
EOF
chmod 600 "$CONFIG_FILE"
echo "Saved to $CONFIG_FILE"
;;
esac
fi
QK_URL="${QK_URL%/}" # Strip trailing slash if present
QK_PATH="${QK_PATH:-/notes}"
case "$QK_PATH" in /*) ;; *) QK_PATH="/$QK_PATH" ;; esac
[ "$QK_PATH" = "//" ] && QK_PATH="/"
EDITOR="${EDITOR:-nano}"
# --- Helpers ---
show_help() {
cat <<EOF
Usage: qk [options] [note]
-h Show this help
-l List local notes
-g PATTERN Search notes (grep)
-t Open today's note (YYYY-MM-DD)
-d NOTE Delete a note (local + remote)
-r OLD NEW Rename a note (local + remote)
-s Sync (pull) all remote notes down to local directory
-b Backup all local notes to ~/qk-YYYY-MM-DD.tar
-c Clear saved credentials and reconfigure
Remote: ${QK_URL}${QK_PATH}
EOF
exit 0
}
api_curl() {
local nf rc host
host=$(echo "$QK_URL" | awk -F/ '{print $3}')
nf=$(mktemp); chmod 600 "$nf"
printf 'machine %s\nlogin %s\npassword %s\n' "$host" "$QK_USER" "$QK_PASS" > "$nf"
curl -s --netrc-file "$nf" "$@"; rc=$?
rm -f "$nf"; return $rc
}
urlenc() {
local s="$1" out="" c i
for (( i=0; i<${#s}; i++ )); do
c="${s:$i:1}"
case "$c" in
[a-zA-Z0-9./_-]) out+="$c" ;;
*) out+=$(printf '%%%02X' "'$c") ;;
esac
done
echo "$out"
}
urldec() {
echo "$1" | awk '{
gsub(/\+/, " ");
while (match($0, /%[0-9a-fA-F]{2}/)) {
hex = substr($0, RSTART+1, 2);
dec = 0;
for (i=1; i<=2; i++) {
c = substr(hex, i, 1);
if (c ~ /[A-F]/) { dec = dec * 16 + (index("ABCDEF", c) + 9) }
else if (c ~ /[a-f]/) { dec = dec * 16 + (index("abcdef", c) + 9) }
else { dec = dec * 16 + c }
}
printf "%s%c", substr($0, 1, RSTART-1), dec;
$0 = substr($0, RSTART+RLENGTH);
}
print $0;
}'
}
get_file_hash() {
if command -v md5sum >/dev/null 2>&1; then md5sum "$1" 2>/dev/null | awk '{print $1}'
elif command -v shasum >/dev/null 2>&1; then shasum "$1" 2>/dev/null | awk '{print $1}'
elif command -v md5 >/dev/null 2>&1; then md5 -q "$1" 2>/dev/null || md5 "$1" 2>/dev/null | awk '{print $1}'
else ls -ln "$1" 2>/dev/null | awk '{print $5,$6,$7,$8}'
fi
}
remote_url() { echo "${QK_URL}$(urlenc "${QK_PATH}/$1")"; }
remote_mkdir() {
local dir="$1" path="" part parts target
if [ -z "$dir" ] || [ "$dir" = "." ]; then target="$QK_PATH"; else target="$QK_PATH/$dir"; fi
IFS='/' read -ra parts <<< "$target"
for part in "${parts[@]}"; do
[ -z "$part" ] && continue
path="$path/$part"
api_curl -X MKCOL "${QK_URL}$(urlenc "$path")" -o /dev/null
done
}
pull_note() {
local file="$1" url http_code
url=$(remote_url "$file")
http_code=$(api_curl -w "%{http_code}" -o "$file" "$url")
case "$http_code" in
200) ;;
404) rm -f "$file" ;;
*) echo "Error: Pull failed (HTTP $http_code)." >&2; exit 1 ;;
esac
}
push_note() {
local file="$1" url http_code dir
dir=$(dirname "$file")
remote_mkdir "$dir"
url=$(remote_url "$file")
http_code=$(api_curl -w "%{http_code}" -o /dev/null -X PUT --data-binary "@$file" "$url")
case "$http_code" in
200|201|204) ;;
*) echo "Error: Push failed (HTTP $http_code)." >&2; exit 1 ;;
esac
}
remote_delete() {
local file="$1" url http_code
url=$(remote_url "$file")
http_code=$(api_curl -w "%{http_code}" -o /dev/null -X DELETE "$url")
case "$http_code" in 200|204|404) ;; *) echo "Warning: Remote delete failed (HTTP $http_code)." >&2 ;; esac
}
remote_rename() {
local old="$1" new="$2" src dst http_code dir
dir=$(dirname "$new")
remote_mkdir "$dir"
src=$(remote_url "$old")
dst=$(remote_url "$new")
http_code=$(api_curl -w "%{http_code}" -o /dev/null -X MOVE -H "Destination: $dst" -H "Overwrite: F" "$src")
case "$http_code" in 201|204|412|404) ;; *) echo "Warning: Remote rename failed (HTTP $http_code)." >&2 ;; esac
}
list_notes() {
echo "=== $NOTES_DIR ==="
if [ -d "$NOTES_DIR" ]; then
(cd "$NOTES_DIR" && find . -type f ! -name "qk.conf" ! -name "qk.sh" ! -path '*/.*' | sed "s|^\./||" | sort)
fi
exit 0
}
search_notes() {
echo "=== Searching for: '$1' ==="
grep -Rin "$1" "$NOTES_DIR" --exclude-dir=".git" --exclude="qk.conf" --exclude="qk.sh"
exit 0
}
delete_note() {
local file="$1"
case "$file" in *.md) ;; *) file="${file}.md" ;; esac
[ -f "$NOTES_DIR/$file" ] || { echo "Error: '$file' not found."; exit 1; }
printf "Delete '%s'? This cannot be undone. [y/N] " "$file"
read -r confirm
case "$confirm" in [Yy]|[Yy][Ee][Ss]) ;; *) echo "Aborted."; exit 0 ;; esac
remote_delete "$file"
rm "$NOTES_DIR/$file"
echo "Deleted '$file'."
exit 0
}
rename_note() {
local old="$1" new="$2"
case "$old" in *.md) ;; *) old="${old}.md" ;; esac
case "$new" in *.md) ;; *) new="${new}.md" ;; esac
[ -f "$NOTES_DIR/$old" ] || { echo "Error: '$old' not found."; exit 1; }
[ -f "$NOTES_DIR/$new" ] && { echo "Error: '$new' already exists."; exit 1; }
remote_rename "$old" "$new"
[ "$(dirname "$new")" != "." ] && mkdir -p "$NOTES_DIR/$(dirname "$new")"
mv "$NOTES_DIR/$old" "$NOTES_DIR/$new"
echo "Renamed '$old' to '$new'."
exit 0
}
sync_all_remote() {
echo "Fetching remote file list..."
local base_encoded_path xml_response raw_paths path dec_path rel_path dir
base_encoded_path=$(urlenc "$QK_PATH")
xml_response=$(api_curl -X PROPFIND -H "Depth: 1" -H "Content-Type: text/xml" "${QK_URL}${base_encoded_path}")
if [ -z "$xml_response" ]; then
echo "Error: Could not retrieve remote file list." >&2
exit 1
fi
# Namespace-agnostic regex to split XML href tags smoothly on varying WebDAV backends
raw_paths=$(echo "$xml_response" | tr -d '\n\r' | sed -E 's/<\/[^>]*href>//g' | sed -E 's/<[^>]*href>/\n/g' | grep -v '^[[:space:]]*$')
echo "$raw_paths" | while IFS= read -r path; do
[ -z "$path" ] && continue
dec_path=$(urldec "$path")
case "$dec_path" in
*"$QK_PATH"*.*.md)
rel_path=$(echo "$dec_path" | sed "s|.*$QK_PATH||" | sed 's|^/||')
echo "Syncing: $rel_path"
dir=$(dirname "$rel_path")
[ "$dir" != "." ] && mkdir -p "$NOTES_DIR/$dir"
(cd "$NOTES_DIR" && pull_note "$rel_path")
;;
esac
done
echo "Sync complete."
exit 0
}
backup_local_notes() {
local backup_file
backup_file="$HOME/qk-$(date '+%Y-%m-%d').tar"
echo "Creating backup at $backup_file..."
if (cd "$NOTES_DIR" && find . -type f ! -name "qk.conf" ! -name "qk.sh" ! -path '*/.*' | tar -cf "$backup_file" -T -); then
echo "Backup created successfully."
else
echo "Error: Backup failed." >&2
exit 1
fi
exit 0
}
reconfigure() {
rm -f "$CONFIG_FILE"
echo "Saved config cleared. Run qk again to set up new credentials."
exit 0
}
# --- Entry Point ---
if [ "$1" = "-r" ]; then
[ -z "$2" ] || [ -z "$3" ] && { echo "Usage: qk -r OLD NEW"; exit 1; }
rename_note "$2" "$3"
fi
ACTION_RUN=0
while getopts "hlg:tcd:sb" opt; do
case $opt in
h) show_help ;;
l) ACTION_RUN=1; list_notes ;;
g) ACTION_RUN=1; search_notes "$OPTARG" ;;
t) NOTE_NAME=$(date '+%Y-%m-%d') ;;
c) ACTION_RUN=1; reconfigure ;;
d) ACTION_RUN=1; delete_note "$OPTARG" ;;
s) ACTION_RUN=1; sync_all_remote ;;
b) ACTION_RUN=1; backup_local_notes ;;
*) show_help ;;
esac
done
[ "$ACTION_RUN" -eq 1 ] && exit 0
shift $((OPTIND - 1))
[ -z "$NOTE_NAME" ] && NOTE_NAME="${1:-note}"
if [ "$NOTE_NAME" = "qk.conf" ] || [ "$NOTE_NAME" = "qk.sh" ]; then
echo "Error: Cannot open runtime files via qk."
exit 1
fi
case "$NOTE_NAME" in *.md) ;; *) NOTE_NAME="${NOTE_NAME}.md" ;; esac
cd "$NOTES_DIR" || { echo "Error: Cannot access $NOTES_DIR"; exit 1; }
[ "$(dirname "$NOTE_NAME")" != "." ] && mkdir -p "$(dirname "$NOTE_NAME")"
echo "Fetching... [WebDAV]"
pull_note "$NOTE_NAME"
PRE_SHA=$(get_file_hash "$NOTE_NAME")
$EDITOR "$NOTE_NAME"
[ -f "$NOTE_NAME" ] || { echo "Note not saved. Cancelled."; exit 0; }
POST_SHA=$(get_file_hash "$NOTE_NAME")
if [ "$PRE_SHA" = "$POST_SHA" ]; then
echo "No changes. Sync skipped."
else
echo "Pushing... [WebDAV]"
push_note "$NOTE_NAME"
echo "Done."
fi