Zero-dependency markdown notes, synced to WebDAV, GitHub, or Dropbox.
gn is a simple bash script that pulls a
markdown note from cloud storage, opens it in
$EDITOR, and pushes it back if you changed
anything. Supports WebDAV (Koofr, Nextcloud, etc.), GitHub,
and Dropbox. No git, no daemons, no heavy apps to install -
just curl and a local folder.
gn.sh above and make it
executable:
# move it somewhere on your PATH
mv gn.sh ~/bin/gn
chmod +x ~/bin/gn
gn, or
anything you like) to hold your notes.
repo scope — this lets gn read
and write files in your repository over the GitHub
API. The setup menu will ask you to paste in your
token, username, and repository name once; these are
saved to your local config file.
files.content.write and
files.content.read, then click
Submit.
# 1. Open this URL (replace YOUR_APP_KEY) to authorise and get a one-time code:
https://www.dropbox.com/oauth2/authorize?client_id=YOUR_APP_KEY&response_type=code&token_access_type=offline
# 2. Exchange the code for a long-lived refresh token:
curl -s -X POST https://api.dropbox.com/oauth2/token \
-d code=YOUR_AUTH_CODE \
-d grant_type=authorization_code \
-d client_id=YOUR_APP_KEY \
-d client_secret=YOUR_APP_SECRET
Copy the refresh_token from the response
— the setup menu will ask for it.
~/gn/gn.conf with
chmod 600 permissions — only your local user
can read the file.
$ gn
No config found at ~/gn/gn.conf - let's set one up.
Select your provider:
1) Koofr (WebDAV)
2) Custom WebDAV Server
3) GitHub
4) Dropbox
Choice [1-4]: 3
GitHub Personal Access Token (input hidden):
GitHub username (repo owner): your-username
Repository name: gn
Save this config for future runs? [Y/n] Y
To clear or alter your configuration later, run
gn -c to wipe gn.conf and re-run
setup.
gn [options] [note]
| Command | What it does |
|---|---|
gn |
Open (or create) default note.md
|
gn ideas |
Open ideas.md - pulls, edits,
pushes if changed
|
gn -t |
Open today's note, YYYY-MM-DD.md
|
gn -l |
List local notes |
gn -g pattern |
Grep across local notes |
gn -d ideas |
Delete a note, local and remote (with confirmation) |
gn -r old new |
Rename a note, local and remote (WebDAV
MOVE)
|
gn -s |
Sync (pull) all remote notes down to local directory |
gn -b |
Backup all local notes to
~/gn-YYYY-MM-DD.tar
|
gn -c |
Clear saved credentials and reconfigure |
gn -h |
Show help |
The full script - copy it directly if you'd rather not download the file.
#!/usr/bin/env bash
# gn - get Notes
# A zero-dependency CLI tool to sync markdown notes via WebDAV, GitHub, or Dropbox.
# Web: gn-notes.pages.dev. Author: Barney Matthews. License: MIT
NOTES_DIR="$HOME/gn"
CONFIG_FILE="$NOTES_DIR/gn.conf"
mkdir -p "$NOTES_DIR"
for cmd in curl grep sed awk find tar base64 tr; 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
gn_USER) gn_USER="$value" ;;
gn_PASS) gn_PASS="$value" ;;
gn_PATH) gn_PATH="$value" ;;
gn_URL) gn_URL="$value" ;;
GIT_TOKEN) GIT_TOKEN="$value" ;;
GIT_OWNER) GIT_OWNER="$value" ;;
GIT_REPO) GIT_REPO="$value" ;;
GIT_API) GIT_API="$value" ;;
DROPBOX_APP_KEY) DROPBOX_APP_KEY="$value" ;;
DROPBOX_APP_SECRET) DROPBOX_APP_SECRET="$value" ;;
DROPBOX_REFRESH_TOKEN) DROPBOX_REFRESH_TOKEN="$value" ;;
DROPBOX_PATH) DROPBOX_PATH="$value" ;;
esac
done < "$CONFIG_FILE"
fi
# --- First-run / reconfigure setup ---
if [ -z "$gn_USER" ] && [ -z "$GIT_TOKEN" ] && [ -z "$DROPBOX_APP_KEY" ]; then
echo "No config found at $CONFIG_FILE - let's set one up."
echo "Select your provider:"
echo "1) Koofr (WebDAV)"
echo "2) Custom WebDAV Server"
echo "3) GitHub"
echo "4) Dropbox"
printf "Choice [1-4]: "
read -r provider_choice
case "$provider_choice" in
2)
printf "WebDAV Server URL (e.g., https://example.com/remote.php/dav/files/user/): "
read -r gn_URL
printf "Username: "
read -r gn_USER
printf "App Password (input hidden): "
stty -echo; read -r gn_PASS; stty echo; echo
printf "Remote notes folder inside WebDAV [/notes]: "
read -r gn_PATH
gn_PATH="${gn_PATH:-/notes}"
;;
3)
printf "GitHub Personal Access Token (input hidden): "
stty -echo; read -r GIT_TOKEN; stty echo; echo
printf "GitHub username (repo owner): "
read -r GIT_OWNER
printf "Repository name: "
read -r GIT_REPO
;;
4)
printf "Dropbox App Key: "
read -r DROPBOX_APP_KEY
printf "Dropbox App Secret (input hidden): "
stty -echo; read -r DROPBOX_APP_SECRET; stty echo; echo
printf "Dropbox Refresh Token (input hidden): "
stty -echo; read -r DROPBOX_REFRESH_TOKEN; stty echo; echo
printf "Remote notes folder in Dropbox [/notes]: "
read -r DROPBOX_PATH
DROPBOX_PATH="${DROPBOX_PATH:-/notes}"
;;
*)
gn_URL="https://app.koofr.net/dav/Koofr"
printf "Koofr email/username: "
read -r gn_USER
printf "Koofr app password (input hidden): "
stty -echo; read -r gn_PASS; stty echo; echo
printf "Remote notes folder [/notes]: "
read -r gn_PATH
gn_PATH="${gn_PATH:-/notes}"
;;
esac
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
{
if [ -n "$GIT_TOKEN" ]; then
printf 'GIT_TOKEN=%s\nGIT_OWNER=%s\nGIT_REPO=%s\n' "$GIT_TOKEN" "$GIT_OWNER" "$GIT_REPO"
elif [ -n "$DROPBOX_APP_KEY" ]; then
printf 'DROPBOX_APP_KEY=%s\nDROPBOX_APP_SECRET=%s\nDROPBOX_REFRESH_TOKEN=%s\nDROPBOX_PATH=%s\n' \
"$DROPBOX_APP_KEY" "$DROPBOX_APP_SECRET" "$DROPBOX_REFRESH_TOKEN" "$DROPBOX_PATH"
else
printf 'gn_URL=%s\ngn_USER=%s\ngn_PASS=%s\ngn_PATH=%s\n' "$gn_URL" "$gn_USER" "$gn_PASS" "$gn_PATH"
fi
} > "$CONFIG_FILE"
chmod 600 "$CONFIG_FILE"
echo "Saved to $CONFIG_FILE"
;;
esac
fi
# --- Detect Sync Engine ---
SYNC_ENGINE=""
if [ -n "$GIT_TOKEN" ] && [ -n "$GIT_OWNER" ] && [ -n "$GIT_REPO" ]; then
SYNC_ENGINE="GITHUB"
GIT_API="${GIT_API:-https://api.github.com/repos/${GIT_OWNER}/${GIT_REPO}/contents}"
elif [ -n "$DROPBOX_APP_KEY" ] && [ -n "$DROPBOX_APP_SECRET" ] && [ -n "$DROPBOX_REFRESH_TOKEN" ]; then
SYNC_ENGINE="DROPBOX"
DROPBOX_PATH="${DROPBOX_PATH:-/notes}"
DROPBOX_PATH="/${DROPBOX_PATH#/}"
[ "$DROPBOX_PATH" = "//" ] && DROPBOX_PATH="/"
elif [ -n "$gn_USER" ] && [ -n "$gn_PASS" ] && [ -n "$gn_URL" ]; then
SYNC_ENGINE="WEBDAV"
gn_URL="${gn_URL%/}"
gn_PATH="${gn_PATH:-/notes}"
case "$gn_PATH" in /*) ;; *) gn_PATH="/$gn_PATH" ;; esac
[ "$gn_PATH" = "//" ] && gn_PATH="/"
else
echo "Error: gn.conf is incomplete. Provide WebDAV (gn_URL, gn_USER, gn_PASS)," >&2
echo "GitHub (GIT_TOKEN, GIT_OWNER, GIT_REPO), or Dropbox (DROPBOX_APP_KEY," >&2
echo "DROPBOX_APP_SECRET, DROPBOX_REFRESH_TOKEN) credentials." >&2
exit 1
fi
# --- Dropbox Auth Init ---
if [ "$SYNC_ENGINE" = "DROPBOX" ]; then
dropbox_refresh() {
local resp pfile
pfile=$(mktemp); chmod 600 "$pfile"
printf 'grant_type=refresh_token&refresh_token=%s&client_id=%s&client_secret=%s' \
"$DROPBOX_REFRESH_TOKEN" "$DROPBOX_APP_KEY" "$DROPBOX_APP_SECRET" > "$pfile"
resp=$(curl -s -X POST "https://api.dropbox.com/oauth2/token" --data-binary "@$pfile")
rm -f "$pfile"
DROPBOX_ACCESS_TOKEN=$(echo "$resp" | awk -F'"' '{for(i=1;i<=NF;i++) if($i=="access_token") {print $(i+2); exit}}')
[ -z "$DROPBOX_ACCESS_TOKEN" ] && { echo "Error: Dropbox token refresh failed: $resp" >&2; exit 1; }
}
dropbox_refresh
fi
EDITOR="${EDITOR:-nano}"
# --- Helpers ---
show_help() {
local remote_info
case "$SYNC_ENGINE" in
GITHUB) remote_info="GitHub: ${GIT_OWNER}/${GIT_REPO}" ;;
DROPBOX) remote_info="Dropbox: ${DROPBOX_PATH}" ;;
*) remote_info="WebDAV: ${gn_URL}${gn_PATH}" ;;
esac
cat <<EOF
Usage: gn [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 ~/gn-YYYY-MM-DD.tar
-c Clear saved credentials and reconfigure
Engine: $SYNC_ENGINE
Remote: $remote_info
EOF
exit 0
}
api_curl() {
local hdr rc
if [ "$SYNC_ENGINE" = "WEBDAV" ]; then
local nf host
host=$(echo "$gn_URL" | awk -F/ '{print $3}')
nf=$(mktemp); chmod 600 "$nf"
printf 'machine %s\nlogin %s\npassword %s\n' "$host" "$gn_USER" "$gn_PASS" > "$nf"
curl -s --netrc-file "$nf" "$@"; rc=$?
rm -f "$nf"; return $rc
else
hdr=$(mktemp); chmod 600 "$hdr"
if [ "$SYNC_ENGINE" = "GITHUB" ]; then
echo "Authorization: token $GIT_TOKEN" > "$hdr"
else
echo "Authorization: Bearer $DROPBOX_ACCESS_TOKEN" > "$hdr"
fi
curl -s -H "@$hdr" "$@"; rc=$?
rm -f "$hdr"; return $rc
fi
}
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 "${gn_URL}$(urlenc "${gn_PATH}/$1")"; }
remote_mkdir() {
local dir="$1" path="" part parts target
if [ -z "$dir" ] || [ "$dir" = "." ]; then target="$gn_PATH"; else target="$gn_PATH/$dir"; fi
IFS='/' read -ra parts <<< "$target"
for part in "${parts[@]}"; do
[ -z "$part" ] && continue
path="$path/$part"
api_curl -X MKCOL "${gn_URL}$(urlenc "$path")" -o /dev/null
done
}
pull_note() {
local file="$1"
if [ "$SYNC_ENGINE" = "GITHUB" ]; then
local url resp http_code content REPLY
url="${GIT_API}/$file"
resp=$(api_curl -w "\n%{http_code}" "$url")
http_code="${resp##*$'\n'}"
REPLY="${resp%$'\n'*}"
[ "$http_code" = "404" ] && return 0
[ "$http_code" != "200" ] && { echo "Error: Pull failed (HTTP $http_code): $REPLY" >&2; exit 1; }
content=$(echo "$REPLY" | awk -F'"' '{for(i=1;i<=NF;i++) if($i=="content") {print $(i+2); exit}}' | tr -d '\\ n[:space:]"')
[ -n "$content" ] && [ "$content" != "null" ] && {
echo "$content" | base64 -d > "$file" 2>/dev/null \
|| echo "$content" | base64 -D > "$file" 2>/dev/null
}
elif [ "$SYNC_ENGINE" = "DROPBOX" ]; then
local path arg http_code
path=$(printf '%s/%s' "$DROPBOX_PATH" "$file" | sed 's#//*#/#g')
arg=$(printf '{"path":"%s"}' "$path")
http_code=$(api_curl -o "$file.tmp" -w "%{http_code}" -X POST \
"https://content.dropboxapi.com/2/files/download" \
-H "Dropbox-API-Arg: $arg")
case "$http_code" in
200) mv "$file.tmp" "$file" ;;
409) rm -f "$file.tmp" ;;
*) rm -f "$file.tmp"; echo "Error: Dropbox pull failed (HTTP $http_code)." >&2; exit 1 ;;
esac
else
local 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
fi
}
push_note() {
local file="$1"
if [ "$SYNC_ENGINE" = "GITHUB" ]; then
local url sha sha_resp http_code content msg pfile REPLY
msg="gn: update $file $(date '+%Y-%m-%d %H:%M:%S')"
url="${GIT_API}/$file"
content=$(base64 -w0 < "$file" 2>/dev/null || base64 < "$file" | tr -d '\n')
pfile=$(mktemp); chmod 600 "$pfile"
sha_resp=$(api_curl -s "$url")
sha=$(echo "$sha_resp" | awk -F'"' '{for(i=1;i<=NF;i++) if($i=="sha") {print $(i+2); exit}}')
if [ -n "$sha" ]; then
printf '{"message":"%s","content":"%s","sha":"%s","branch":"main"}' \
"$msg" "$content" "$sha" > "$pfile"
else
printf '{"message":"%s","content":"%s","branch":"main"}' \
"$msg" "$content" > "$pfile"
fi
resp=$(api_curl -w "\n%{http_code}" -X PUT -H "Content-Type: application/json" --data-binary "@$pfile" "$url")
rm -f "$pfile"
http_code="${resp##*$'\n'}"
REPLY="${resp%$'\n'*}"
[[ "$http_code" =~ ^(200|201)$ ]] || { echo "Error: Push failed (HTTP $http_code): $REPLY" >&2; exit 1; }
elif [ "$SYNC_ENGINE" = "DROPBOX" ]; then
local path arg resp http_code REPLY
path=$(printf '%s/%s' "$DROPBOX_PATH" "$file" | sed 's#//*#/#g')
arg=$(printf '{"path":"%s","mode":"overwrite","autorename":false,"mute":true}' "$path")
resp=$(api_curl -w "\n%{http_code}" -X POST \
"https://content.dropboxapi.com/2/files/upload" \
-H "Dropbox-API-Arg: $arg" \
-H "Content-Type: application/octet-stream" \
--data-binary "@$file")
http_code="${resp##*$'\n'}"
REPLY="${resp%$'\n'*}"
[ "$http_code" = "200" ] || { echo "Error: Dropbox push failed (HTTP $http_code): $REPLY" >&2; exit 1; }
else
local 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
fi
}
remote_delete() {
local file="$1" msg="${2:-gn: delete $1}"
if [ "$SYNC_ENGINE" = "GITHUB" ]; then
local url sha sha_resp http_code resp pfile REPLY
url="${GIT_API}/$file"
pfile=$(mktemp); chmod 600 "$pfile"
sha_resp=$(api_curl -s "$url")
sha=$(echo "$sha_resp" | awk -F'"' '{for(i=1;i<=NF;i++) if($i=="sha") {print $(i+2); exit}}')
if [ -z "$sha" ]; then rm -f "$pfile"; return 0; fi
printf '{"message":"%s","sha":"%s","branch":"main"}' "$msg" "$sha" > "$pfile"
resp=$(api_curl -w "\n%{http_code}" -X DELETE -H "Content-Type: application/json" --data-binary "@$pfile" "$url")
rm -f "$pfile"
http_code="${resp##*$'\n'}"
[[ "$http_code" =~ ^(200|204)$ ]] || echo "Warning: Remote delete failed (HTTP $http_code)." >&2
elif [ "$SYNC_ENGINE" = "DROPBOX" ]; then
local path arg resp http_code
path=$(printf '%s/%s' "$DROPBOX_PATH" "$file" | sed 's#//*#/#g')
arg=$(printf '{"path":"%s"}' "$path")
resp=$(api_curl -w "\n%{http_code}" -X POST \
-H "Content-Type: application/json" --data-binary "$arg" \
"https://api.dropboxapi.com/2/files/delete_v2")
http_code="${resp##*$'\n'}"
[[ "$http_code" =~ ^(200|409)$ ]] || echo "Warning: Dropbox delete failed (HTTP $http_code)." >&2
else
local 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
fi
}
remote_rename() {
local old="$1" new="$2"
if [ "$SYNC_ENGINE" = "GITHUB" ] || [ "$SYNC_ENGINE" = "DROPBOX" ]; then
# No native rename: push new, delete old (caller handles local mv)
cp "$NOTES_DIR/$old" "$NOTES_DIR/$new"
push_note "$new"
remote_delete "$old" "gn: rename $old to $new"
rm -f "$NOTES_DIR/$new" # caller will mv old→new
else
local 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
fi
}
list_notes() {
echo "=== $NOTES_DIR ==="
if [ -d "$NOTES_DIR" ]; then
(cd "$NOTES_DIR" && find . -type f ! -name "gn.conf" ! -name "gn.sh" ! -path '*/.*' | sed "s|^\./||" | sort)
fi
exit 0
}
search_notes() {
echo "=== Searching for: '$1' ==="
grep -Rin "$1" "$NOTES_DIR" --exclude-dir=".git" --exclude="gn.conf" --exclude="gn.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" "gn: 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... [$SYNC_ENGINE]"
if [ "$SYNC_ENGINE" = "GITHUB" ]; then
local resp http_code REPLY paths path
resp=$(api_curl -w "\n%{http_code}" "${GIT_API}")
http_code="${resp##*$'\n'}"
REPLY="${resp%$'\n'*}"
[ "$http_code" != "200" ] && { echo "Error: Could not list remote files (HTTP $http_code)." >&2; exit 1; }
# Extract .md filenames from JSON array
paths=$(echo "$REPLY" | awk -F'"' '{for(i=1;i<=NF;i++) if($i=="name") print $(i+2)}' | grep '\.md$')
echo "$paths" | while IFS= read -r path; do
[ -z "$path" ] && continue
echo "Syncing: $path"
(cd "$NOTES_DIR" && pull_note "$path")
done
elif [ "$SYNC_ENGINE" = "DROPBOX" ]; then
local resp http_code REPLY arg paths path
arg=$(printf '{"path":"%s","recursive":true}' "$DROPBOX_PATH")
resp=$(api_curl -w "\n%{http_code}" -X POST \
-H "Content-Type: application/json" --data-binary "$arg" \
"https://api.dropboxapi.com/2/files/list_folder")
http_code="${resp##*$'\n'}"
REPLY="${resp%$'\n'*}"
[ "$http_code" != "200" ] && { echo "Error: Could not list remote files (HTTP $http_code)." >&2; exit 1; }
# Extract .md paths relative to DROPBOX_PATH
paths=$(echo "$REPLY" | awk -F'"' '{for(i=1;i<=NF;i++) if($i=="path_display") print $(i+2)}' | grep '\.md$')
echo "$paths" | while IFS= read -r path; do
[ -z "$path" ] && continue
rel_path=$(echo "$path" | sed "s|^$DROPBOX_PATH/||" | sed 's|^/||')
echo "Syncing: $rel_path"
dir=$(dirname "$rel_path")
[ "$dir" != "." ] && mkdir -p "$NOTES_DIR/$dir"
(cd "$NOTES_DIR" && pull_note "$rel_path")
done
else
local base_encoded_path xml_response raw_paths path dec_path rel_path dir
base_encoded_path=$(urlenc "$gn_PATH")
xml_response=$(api_curl -X PROPFIND -H "Depth: 1" -H "Content-Type: text/xml" "${gn_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
*"$gn_PATH"*.*.md)
rel_path=$(echo "$dec_path" | sed "s|.*$gn_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
fi
echo "Sync complete."
exit 0
}
backup_local_notes() {
local backup_file
backup_file="$HOME/gn-$(date '+%Y-%m-%d').tar"
echo "Creating backup at $backup_file..."
if (cd "$NOTES_DIR" && find . -type f ! -name "gn.conf" ! -name "gn.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 gn again to set up new credentials."
exit 0
}
# --- Entry Point ---
if [ "$1" = "-r" ]; then
[ -z "$2" ] || [ -z "$3" ] && { echo "Usage: gn -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" = "gn.conf" ] || [ "$NOTE_NAME" = "gn.sh" ]; then
echo "Error: Cannot open runtime files via gn."
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... [$SYNC_ENGINE]"
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... [$SYNC_ENGINE]"
push_note "$NOTE_NAME"
echo "Done."
fi