Add the WebDAV-syncing laptop client

Fixes the three defects PLAN.md section 6 lists. The previous-note glob is
constrained to the date pattern, so a .sync-conflict- sibling can no longer
be selected as the file to carry forward, which is the likeliest origin of
how those conflicts spread. The retitle is line 1 only, so a body line like
"meeting moved to 2026-09-08" is no longer silently advanced a day. The dead
`&& cd -` is gone.

Pull before the edit, push after. Error handling is shaped by the CrowdSec
rules in section 5 rather than by generic retry logic: a 401 stops dead with
a message, a 404 means the timer has not run yet so it creates once and
never polls, and anything else degrades to editing the local copy.

The offline fallback shells out to the same prune.py the server rollover
uses, installed to ~/.local/lib/todo, so the two cannot drift.

Since SFTPGo ignores If-Match, the precondition is enforced client side by
comparing the ETag captured at pull against a HEAD before the PUT. A remote
that moved mid-edit produces a .sync-conflict- sibling and a non-zero exit
rather than an overwrite. Real merging is phase 5; this just makes phase 2
incapable of losing an edit.

The credential comes from rbw and reaches curl over stdin, never argv, where
any other user's ps could read it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PU5ZFfQFtDTFqqhvMTWdGH
This commit is contained in:
Connor JohnstoneandClaude Opus 5 committed 2026-09-09 18:59:16 -04:00
1 parent 45f0fca444
commit 1eef5e5ae3
3 files changed
+179

No files matched your search

+17
View File
@@ -124,6 +124,23 @@ inequality only, never against a clock. It is recorded because the drift
silently affected everything else on that mount, `rsync --update` and
incremental backups being the obvious victims.
## `EDITOR=x todo` cannot work, because zshenv wins
`~/.zshenv` exports `EDITOR` unconditionally, and zsh sources zshenv on *every*
invocation, non-interactive scripts included. So a `#!/bin/zsh` script sees the
zshenv value no matter what the caller put in the environment:
```
$ env EDITOR=/bin/true zsh -c 'echo $EDITOR'
/home/connorjohnstone/.local/share/bob/nvim-bin/nvim
```
Harmless in daily use, since the exported value is the wanted editor anyway, but
it makes the edit step untestable and would quietly ignore a deliberate
override. Hence `TODO_EDITOR`, which matches the other `TODO_*` knobs.
Found the hard way: a test that set `EDITOR=true` opened a real nvim and hung.
## Reconciliation, phase 0, completed 2026-09-09
| File | Resolution |
+21
View File
@@ -0,0 +1,21 @@
#!/bin/sh
# Install the laptop client. Idempotent; keeps one backup of whatever it replaces.
set -eu
HERE=$(cd "$(dirname "$0")" && pwd)
BIN="$HOME/.local/bin"
LIB="$HOME/.local/lib/todo"
mkdir -p "$BIN" "$LIB"
if [ -e "$BIN/todo" ] && ! cmp -s "$HERE/todo" "$BIN/todo"; then
cp -p "$BIN/todo" "$BIN/todo.backup"
echo "previous todo saved as $BIN/todo.backup"
fi
install -m 0755 "$HERE/todo" "$BIN/todo"
# One prune implementation, shared with the server rollover, so the client's
# offline fallback cannot drift from what the timer would have produced.
install -m 0755 "$HERE/../rollover/prune.py" "$LIB/prune.py"
echo "installed $BIN/todo and $LIB/prune.py"
Executable
+141
View File
@@ -0,0 +1,141 @@
#!/bin/zsh
# Open today's note, syncing it through WebDAV around the edit.
#
# The server rolls the note forward at 00:01, so normally this just fetches a
# file that already exists. Everything here is arranged so that losing the
# network degrades to editing the local copy, never to losing an edit.
emulate -L zsh
set -o errexit -o nounset -o pipefail
NOTES=${TODO_NOTES:-$HOME/docs/notes/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/1.0
today=$(date --iso-8601)
current=$NOTES/$today.md
mkdir -p $NOTES
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}' }
# ---------------------------------------------------------------- pull
base_etag=""
have_remote=0
if [[ -z $dav_pass ]]; then
note "no credential from rbw, working offline"
else
code=$(dav -o $work/pull -D $work/pull.h -w '%{http_code}' \
$REMOTE/$today.md) || code=000
case $code in
200)
base_etag=$(etag_of $work/pull.h)
have_remote=1
if [[ -e $current ]] && ! cmp -s $current $work/pull; then
note "local and remote both changed since last sync, keeping local"
note "remote copy saved alongside it for comparison"
cp $work/pull $NOTES/$today.remote-$(date +%H%M%S).md
else
cp $work/pull $current
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
before=$(md5sum < $current)
# ---------------------------------------------------------------- edit
$EDIT $current
# ---------------------------------------------------------------- push
[[ $(md5sum < $current) == $before ]] && { note "unchanged, nothing to push"; exit 0 }
[[ -n $dav_pass ]] || { note "no credential, leaving changes local"; exit 0 }
# SFTPGo returns ETags but ignores If-Match on PUT, so the precondition has to
# be checked here instead. The race window is milliseconds wide for one person.
if (( have_remote )); then
dav -I -D $work/head.h -o /dev/null $REMOTE/$today.md >/dev/null 2>&1 || true
now_etag=$(etag_of $work/head.h)
if [[ -n $now_etag && -n $base_etag && $now_etag != $base_etag ]]; then
sibling=$NOTES/$today.sync-conflict-$(date +%Y%m%d-%H%M%S)-${HOST:u}.md
cp $current $sibling
die "remote changed while you were editing. yours saved as ${sibling:t}, nothing overwritten"
fi
fi
code=$(dav -X PUT --data-binary @$current -o /dev/null -w '%{http_code}' \
$REMOTE/$today.md) || code=000
case $code in
200|201|204) 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