Replaces the previous repo, which had split into two histories that never met (mainframe on a dead GitLab remote, the laptops on Gitea) with 146 dirty files across three machines and the NAS never enrolled at all. Branch-per-machine is gone. One main, with host differences expressed as small files under hosts/<hostname>/ rather than as branches, so there is nothing to merge. The reconciled zsh layer reduces 15-33 line forks to 1-7 effective lines per host; distro differences (oh-my-zsh prefix, syntax-highlighting path, fd vs fdfind) are probed in common/ instead. Fresh history: the old one carried six plaintext credentials, 45 MB of mail caches, browser caches and vendored binaries. 5,096 tracked files and 144 MB become 462 files and 2.6 MB. The .gitignore is now an allowlist, which is what keeps that true. Root cause of the rot: ~/.local/bin was a symlink to scripts/ with GOPATH inside it, so every go install wrote into version control (2.2 GB on the work laptop). PATH now points at the repo instead of the reverse. Also: Hyprland replaces sway and is sourced in two halves so $browser is defined before use; singleton automations carry ConditionHost= alongside host-layer-only placement; ddns moves from cron to a guarded timer; package manifests and pkg-snapshot/pkg-restore replace the X11-era install_scripts/; networkmanager-dmenu added to system76 (the binding always existed, the package never did).
172 lines
6.7 KiB
Bash
Executable File
172 lines
6.7 KiB
Bash
Executable File
#!/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
|