#!/bin/zsh # Open today's note, reconciling it with the server around the edit. # # The server rolls the note forward at 00:01, so normally this fetches a file # that already exists. Everything is arranged so that losing the network # degrades to editing the local copy, never to losing an edit. # # Alongside the note, a pristine copy of the text last agreed with the server is # kept, with its ETag. Without it there is no way to tell "I have unsaved edits" # from "I am simply behind", and the second case gets misreported as the first # every time the phone pushes. emulate -L zsh set -o errexit -o nounset -o pipefail NOTES=${TODO_NOTES:-$HOME/docs/notes/todo} STATE=${TODO_STATE:-${XDG_STATE_HOME:-$HOME/.local/state}/todo} REMOTE=${TODO_REMOTE:-https://files.rcjohnstone.com/docs/notes/todo} DAV_USER=${TODO_USER:-connor} RBW_ENTRY=${TODO_RBW_ENTRY:-SFTPGo} PRUNE=${TODO_PRUNE:-$HOME/.local/lib/todo/prune.py} # zshenv exports EDITOR on every invocation, including this script's own, so # `EDITOR=x todo` cannot work. TODO_EDITOR is the override that does. EDIT=${TODO_EDITOR:-${EDITOR:-nvim}} AGENT=todo-cli/2.0 today=$(date --iso-8601) current=$NOTES/$today.md base=$STATE/base/$today.md base_etag=$STATE/base/$today.etag mkdir -p $NOTES $STATE/base work=$(mktemp -d); trap 'rm -rf $work' EXIT note() { print -u2 -- "todo: $*" } die() { print -u2 -- "todo: $*"; exit 1 } # Password stays in a variable and reaches curl over stdin. Passing it as -u # would put it in argv, where any other user's `ps` can read it. dav_pass="" if command -v rbw >/dev/null 2>&1; then dav_pass=$(rbw get $RBW_ENTRY 2>/dev/null) || dav_pass="" fi dav() { [[ -n $dav_pass ]] || return 64 printf 'user = "%s:%s"\n' $DAV_USER $dav_pass | curl -sS --config - -A $AGENT --max-time 15 "$@" } etag_of() { grep -i '^etag:' $1 2>/dev/null | tr -d '\r' | awk '{print $2}' } adopt() { # text-file etag [[ $1 == $current ]] || cp $1 $current cp $1 $base print -r -- ${2:-} > $base_etag } set_aside() { # text-file -> prints the filename used local name=$today.sync-conflict-$(date +%Y%m%d-%H%M%S)-${HOST:u}.md cp $1 $NOTES/$name dav -X PUT --data-binary @$1 -o /dev/null $REMOTE/$name >/dev/null 2>&1 || true print -r -- $name } # ---------------------------------------------------------------- pull remote_hash="" if [[ -z $dav_pass ]]; then note "no credential from rbw, working offline" else code=$(dav -o $work/theirs -D $work/h -w '%{http_code}' $REMOTE/$today.md) || code=000 case $code in 200) their_etag=$(etag_of $work/h) remote_hash=$(md5sum < $work/theirs) if [[ ! -e $current ]]; then adopt $work/theirs $their_etag elif [[ ! -e $base ]]; then # No record of a previous agreement, so nothing to merge from. if cmp -s $current $work/theirs; then adopt $work/theirs $their_etag else note "no sync history, yours saved as $(set_aside $current)" adopt $work/theirs $their_etag fi elif [[ -e $base_etag && -n $their_etag && $their_etag == $(<$base_etag) ]]; then : # server has not moved since the last agreement elif git merge-file -p $current $base $work/theirs > $work/merged 2>/dev/null; then # git rather than diff3. diff3 calls it a conflict even when # both sides made the same change, and both devices ticking the # same box is an ordinary thing to happen here. cp $work/merged $current if cmp -s $current $work/theirs; then # Nothing local to contribute, so this is the new agreement. adopt $work/theirs $their_etag else note "merged in changes from the server" fi else # Both sides changed the same lines. The server's copy becomes # the note and the local one goes beside it, which is the # convention already in this directory. note "conflict, yours saved as $(set_aside $current)" adopt $work/theirs $their_etag fi ;; 404) # The 00:01 timer has not run, or the clock disagrees about the day. # One create, never a poll loop: repeated 404s trip http-probing. note "no $today.md on the server yet, creating it here" ;; 401) # Never retry: repeated 401s from one IP trip http-generic-bf and # earn a four hour ban that no whitelist covers. die "credentials rejected. fix the $RBW_ENTRY entry, do not re-run blindly" ;; 000) note "server unreachable, working offline" ;; *) note "server returned $code, working offline" ;; esac fi # ------------------------------------------------------- local fallback if [[ ! -e $current ]]; then # Match the date glob exactly. A bare `ls | tail -1` can select a # .sync-conflict- sibling, which is how divergent notes spread before. previous=($NOTES/[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9].md(N)) if (( ${#previous} )); then src=${previous[-1]} if [[ -x $PRUNE ]]; then $PRUNE $src > $work/new else note "prune not found at $PRUNE, carrying the note forward unpruned" cp $src $work/new fi # Only line 1. A global replace would rewrite any date in the body, so # "meeting moved to 2026-09-08" would silently become today. sed -i "1s/.*/# $today/" $work/new cp $work/new $current note "created $today.md from ${src:t}" else printf '# %s\n' $today > $current note "started a fresh $today.md" fi fi # ---------------------------------------------------------------- edit $EDIT $current # ---------------------------------------------------------------- push [[ $(md5sum < $current) == $remote_hash ]] && { note "in step with the server"; exit 0 } [[ -n $dav_pass ]] || { note "no credential, leaving changes local"; exit 0 } code=$(dav -X PUT --data-binary @$current -o /dev/null -w '%{http_code}' $REMOTE/$today.md) || code=000 case $code in 200|201|204) # The ETag a PUT returns is not the file's own, so it is read back # rather than believed. See FINDINGS.md. dav -I -D $work/ph -o /dev/null $REMOTE/$today.md >/dev/null 2>&1 || true adopt $current "$(etag_of $work/ph)" note "pushed $today.md" ;; 401) die "credentials rejected on push. changes are safe locally" ;; 000) note "push failed, server unreachable. changes are safe locally" ;; *) note "push returned $code. changes are safe locally" ;; esac