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 above and make it
executable:
# move it somewhere on your PATH
mv mn.sh ~/bin/mn
chmod +x ~/bin/mn
$ 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.
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 |
GET payload request up to your selected
WebDAV server engine endpoint. A
404 response status code simply signals a
fresh document that does not yet exist on the remote
repository.
$EDITOR (falling back gracefully to
nano). Local cryptographic hashes generated
before and after composition sessions determine if
modifications occurred.
PUT mechanism. Nested parent trees generate
dynamically via structural sequential
MKCOL collection passes.
-s targets
a structural collection mapping query via
PROPFIND passes, utilizing standard
namespace-agnostic traversal patterns to securely sync
across distinct target architectures.
-b wraps standard components safely via a
fast local tar command.
.netrc system profile layer for
each isolated execution sweep, purging the ephemeral
footprint immediately afterwards to protect access
secrets from system process logs (ps
leaks).
mn deployments on every terminal node.
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