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:
1 parent
45f0fca444
commit
1eef5e5ae3
3 files changed
+179
No files matched your search
+17
@@ -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 |
|
||||
|
||||
Executable
+21
@@ -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
@@ -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
|
||||
Reference in new issue
Block a user