whoami

qk // quick notes

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 - save to ~/bin/qk, chmod +x

Setup

  1. Download qk.sh above and make it executable:
# move it somewhere on your PATH
mv qk.sh ~/bin/qk
chmod +x ~/bin/qk
  1. Prepare your webDAV provider credentials:

Option A: Koofr Cloud Storage

  • Sign up free or log in at app.koofr.net.
  • Go to Account settings → Password and generate an App Password. The script needs this specific app-token, not your main web portal login password.

Option B: General WebDAV (Nextcloud, ownCloud, etc.)

  • Locate your service's primary WebDAV connection URL. (e.g., Nextcloud users can find this via Files → Files settings in the lower-left sidebar corner).
  • Ensure you have a dedicated App Password generated through your server's security settings panel for qk to use.
  1. Run the script. if no configuration file is found, it will run an interactive setup menu to configure your webDAV connection:
$ 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.

Usage

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

Notes

Heads up Sync is last-write-wins. There's no merge or conflict detection - if you edit the same note from two machines without syncing in between, the second push overwrites the first.
Tip Notes are raw markdown documents inside your wedDAV server directories, so you can easily access, and modify your notes through a mobile app or web browser (E.G. koofr.net ).

Source

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