whoami

mn // my notes

Zero-dependency markdown notes, synced to Koofr or any standard WebDAV endpoint.

mn is a single bash script. It pulls a markdown note from your secure 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.

mn.sh - save to ~/bin/mn, chmod +x

Setup

  1. Download mn.sh above and make it executable:
# move it somewhere on your PATH
mv mn.sh ~/bin/mn
chmod +x ~/bin/mn
  1. Prepare your cloud 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, Infomaniak, 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 maximum safety.
  1. Run the script. With no configuration profile found, it will drop into an interactive setup menu to choose your backend style:
$ mn
No config found at ~/mn/mn.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 are stored securely at ~/mn/mn.conf with restrictive chmod 600 permissions. To clear or alter your configuration later, run mn -c to create a new mn.conf file.

Usage

mn [options] [note]
Command What it does
mn Open (or create) default note.md
mn ideas Open ideas.md - pulls, edits, pushes if changed
mn -t Open today's note, YYYY-MM-DD.md
mn -l List local notes
mn -g pattern Grep across local notes
mn -d ideas Delete a note, local and remote (with confirmation)
mn -r old new Rename a note, local and remote (WebDAV MOVE)
mn -s Sync (pull) all remote notes down to local directory
mn -b Backup all local notes to ~/mn-YYYY-MM-DD.tar
mn -c Clear saved credentials and reconfigure
mn -h Show help

How it works

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, unencumbered markdown documents inside your remote server directories, meaning you can easily access, review, and modify your text vaults through any mobile app or native browser interface without requiring mn deployments on every terminal node.

Source

The full script - copy it directly if you'd rather not download the file.

#!/usr/bin/env bash
# mn - My Notes
# A zero-dependency CLI tool to sync markdown notes via a WebDAV endpoint.
# Author: Barney Matthews (Modified for generic WebDAV support). License: MIT

NOTES_DIR="$HOME/mn"
CONFIG_FILE="$NOTES_DIR/mn.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
            MN_USER) MN_USER="$value" ;;
            MN_PASS) MN_PASS="$value" ;;
            MN_PATH) MN_PATH="$value" ;;
            MN_URL)  MN_URL="$value" ;;
        esac
    done < "$CONFIG_FILE"
fi

# --- First-run / reconfigure setup ---
if [ -z "$MN_USER" ] || [ -z "$MN_PASS" ] || [ -z "$MN_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 MN_URL
            printf "Username: "
            read -r MN_USER
            printf "App Password (input hidden): "
            stty -echo; read -r MN_PASS; stty echo; echo
            printf "Remote notes folder inside WebDAV [/notes]: "
            read -r MN_PATH
            ;;
        *)
            # Default Koofr Setup
            MN_URL="https://app.koofr.net/dav/Koofr"
            printf "Koofr email/username: "
            read -r MN_USER
            printf "Koofr app password (input hidden): "
            stty -echo; read -r MN_PASS; stty echo; echo
            printf "Remote notes folder [/notes]: "
            read -r MN_PATH
            ;;
    esac

    MN_PATH="${MN_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
MN_URL=$MN_URL
MN_USER=$MN_USER
MN_PASS=$MN_PASS
MN_PATH=$MN_PATH
EOF
            chmod 600 "$CONFIG_FILE"
            echo "Saved to $CONFIG_FILE"
            ;;
    esac
fi

MN_URL="${MN_URL%/}" # Strip trailing slash if present
MN_PATH="${MN_PATH:-/notes}"
case "$MN_PATH" in /*) ;; *) MN_PATH="/$MN_PATH" ;; esac
[ "$MN_PATH" = "//" ] && MN_PATH="/"

EDITOR="${EDITOR:-nano}"

# --- Helpers ---
show_help() {
    cat <<EOF
Usage: mn [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 ~/mn-YYYY-MM-DD.tar
  -c          Clear saved credentials and reconfigure

Remote: ${MN_URL}${MN_PATH}
EOF
    exit 0
}

api_curl() {
    local nf rc host
    host=$(echo "$MN_URL" | awk -F/ '{print $3}')
    nf=$(mktemp); chmod 600 "$nf"
    printf 'machine %s\nlogin %s\npassword %s\n' "$host" "$MN_USER" "$MN_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 "${MN_URL}$(urlenc "${MN_PATH}/$1")"; }

remote_mkdir() {
    local dir="$1" path="" part parts target
    if [ -z "$dir" ] || [ "$dir" = "." ]; then target="$MN_PATH"; else target="$MN_PATH/$dir"; fi
    IFS='/' read -ra parts <<< "$target"
    for part in "${parts[@]}"; do
        [ -z "$part" ] && continue
        path="$path/$part"
        api_curl -X MKCOL "${MN_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 "mn.conf" ! -name "mn.sh" ! -path '*/.*' | sed "s|^\./||" | sort)
    fi
    exit 0
}

search_notes() {
    echo "=== Searching for: '$1' ==="
    grep -Rin "$1" "$NOTES_DIR" --exclude-dir=".git" --exclude="mn.conf" --exclude="mn.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 "$MN_PATH")
    xml_response=$(api_curl -X PROPFIND -H "Depth: 1" -H "Content-Type: text/xml" "${MN_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>/\\n/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
            *"$MN_PATH"*.*.md)
                rel_path=$(echo "$dec_path" | sed "s|.*$MN_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/mn-$(date '+%Y-%m-%d').tar"
    echo "Creating backup at $backup_file..."

    if (cd "$NOTES_DIR" && find . -type f ! -name "mn.conf" ! -name "mn.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 mn again to set up new credentials."
    exit 0
}

# --- Entry Point ---
if [ "$1" = "-r" ]; then
    [ -z "$2" ] || [ -z "$3" ] && { echo "Usage: mn -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" = "mn.conf" ] || [ "$NOTE_NAME" = "mn.sh" ]; then
    echo "Error: Cannot open runtime files via mn."
    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