Add a README for the repo

Written for two readers: me in a year having forgotten all of it, and
anyone who stumbles on the repo and finds the idea worth stealing.

Leads with why the design gives things up rather than what it does, since
the giving up is the interesting part. Records the operational facts that
live nowhere in the code, points at FINDINGS.md for the things worth not
rediscovering, and ends with the changes this system should refuse.

Also marks validate.py and test_prune.py executable, since the README
invokes them directly.

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 Johnstone
2026-09-09 21:04:05 -04:00
co-authored by Claude Opus 5
parent 2bf910757a
commit 19049fc40c
3 changed files with 215 additions and 0 deletions
+215
View File
@@ -0,0 +1,215 @@
# todo
Daily notes as plain markdown files, one per day, synced between a laptop and a
phone without either of them thinking about sync.
This started as fifteen lines of zsh that outlasted every notes app I have
tried:
```zsh
cd ~/docs/notes/todo
today="$(date --iso-8601)"
current="$today.md"
previous="$(ls | sort | tail -n 1)"
previous_date="$(echo "$previous" | sed "s/.md//")"
if ! [ -e $current ]
then
cp $previous $current
sed -i "s/$previous_date/$today/g" $current
fi
nvim $current && cd -
```
If today's file does not exist, copy yesterday's forward, rewrite the date, open
it. That is the whole idea. Unfinished work carries itself into today without
being re-entered, finished work falls away, and the notes stay files you can
`cat`, `grep`, and edit with anything.
This repo extends that to a phone **without turning it into an app**. The notes
are still `YYYY-MM-DD.md` in a directory. Nothing here owns them.
## The idea, for anyone who finds this interesting
Four decisions do most of the work, and all four are about giving up features to
get reliability.
**Rollover happens on the server, not the client.** Two clients each creating
today's file independently is a guaranteed conflict, and is almost certainly
where the Syncthing conflict files in my history came from. One writer on the
always-on box removes that by construction. Every client then degrades to
"open the file named `$(date -I).md`", which is a much lower bar than "decide
what today's file should contain".
**The prune rule is deliberately timid.** A finished top-level item disappears
tomorrow. A finished item under an unfinished parent stays, because grouping is
how context gets carried and a done child is information, not clutter. Headings
are never removed even if a section empties. The rule only ever deletes whole
completed subtrees, so it cannot make a structural judgement it is not entitled
to make.
**The editor styles markdown in place instead of hiding it.** `**bold**` renders
bold with the asterisks still showing. Hiding markers requires a non-identity
`OffsetMapping`, which makes the cursor jump in ways that feel broken. Keeping
every character means the mapping is the identity, so cursor and selection
arithmetic is simply correct. It also keeps the file honest plain text, which is
the entire point.
**Divergence is merged, not resolved by whoever wrote last.** A box ticked on
the phone while a different box was ticked on the laptop is two edits to
different lines, and a three way merge takes both without asking anyone
anything. Only a genuine same line collision produces a conflict, and that
writes a `.sync-conflict-` sibling rather than discarding either side.
No new infrastructure was added for any of this. It rides on a WebDAV endpoint
that already existed.
## How it fits together
```
/home/connor/docs/notes/todo/ <- canonical
|
+------------------+------------------+
| |
systemd timer 00:01 SFTPGo WebDAV
(rollover + prune) |
files.rcjohnstone.com
|
+---------------+---------------+
| |
laptop: todo script Android app
(pull, nvim, merge, push) (offline first, merge)
```
The notes live in `~/docs/notes/todo/`. **This repo holds only the tooling.**
The two never merge.
## What is where
```
rollover/ the 00:01 job that runs on the server
prune.py the prune rule, and the only implementation of it
rollover.py copy yesterday forward, retitle, prune, write today
validate.py run the prune over every historical note and assert invariants
review.sh regenerate the whole corpus diff for eyeballing
test_prune.py 14 unit tests
install.sh deploy to the server
client/ the laptop side
todo pull, merge, edit, push
install.sh installs to ~/.local/bin and ~/.local/lib/todo
android/ the phone side
app/src/main/java/com/example/todo/
editor/ markdown scanning, highlighting, three way merge
data/ WebDAV, credentials, local cache
ui/note/ the single screen
app/src/test/ 41 JVM unit tests
deploy.sh build, install, logcat
```
`PLAN.md` is the plan of record, written before any of it existed. `FINDINGS.md`
is what turned out to be true, and is the file to read before assuming anything
in `PLAN.md` still holds.
## Using it
**On the laptop**, `todo` opens today's note. It pulls first, merges anything
that arrived from the phone, opens `$EDITOR`, and pushes on exit. If the server
is unreachable it edits the local copy and pushes next time. Nothing is lost by
being offline.
**On the phone**, one tap on the homescreen icon. The local copy opens instantly
whether or not there is signal. Tapping the `[ ]` or `[x]` of a checkbox toggles
it; tapping anywhere else places the cursor as usual. Sync happens when the app
comes to the foreground and when Save is pressed, never on a timer.
**Overnight**, the timer on the server writes tomorrow's note from today's,
minus anything finished.
## Picking this back up later
Everything is already installed and running. This is what to check first.
```sh
# is the nightly rollover healthy?
ssh mainframe 'systemctl list-timers todo-rollover.timer; journalctl -u todo-rollover -n 20'
# does the prune still do what you expect over the whole corpus?
rollover/validate.py # invariants, must report 0 failures
rollover/review.sh # writes ~/todo-prune-review.diff for reading
# tests
python3 rollover/test_prune.py
cd android && ./gradlew testDebugUnitTest
```
### Rebuilding the app
The SDK is at `~/Android/Sdk`. Note that `sdkmanager` is deprecated; the current
tool is the `android` CLI in `cmdline-tools/latest/bin`.
```sh
cd android
./gradlew assembleDebug
./deploy.sh # build and install to whatever is attached
./deploy.sh pair 10.0.0.50:41234 123456 # wireless debugging, one time
./deploy.sh connect 10.0.0.50:43671 # after a phone reboot
./deploy.sh logcat
```
Wireless debugging uses **two different ports**: one for the one time pairing
and another, shown on the Wireless debugging screen itself, for connecting.
Mixing them up makes `adb pair` appear to work while no device ever shows up.
`adb mdns services` prints both if in doubt.
On this phone, HyperOS refuses `adb install` for a package it has not seen
before, with `INSTALL_FAILED_USER_RESTRICTED`, regardless of the developer
options being on. Push the APK to `/sdcard/Download/` and install it once by
hand from the file manager; **updates** over `adb install -r` work fine
afterwards. See `FINDINGS.md`.
### Where things live
| Thing | Where |
|---|---|
| Notes | `~/docs/notes/todo/` on the laptop, `/home/connor/docs/notes/todo/` on mainframe |
| Rollover | `mainframe:/home/connor/.local/lib/todo/`, units in `/etc/systemd/system/` |
| Laptop client | `~/.local/bin/todo`, prune at `~/.local/lib/todo/prune.py` |
| Sync state | `~/.local/state/todo/base/` (pristine copy plus ETag, per day) |
| WebDAV | `https://files.rcjohnstone.com/docs/notes/todo/` |
| Credential | the `SFTPGo` entry in `rbw`, user `connor` |
| Signing key | `/nas/docs/backup/android/debug.keystore`, backed up nightly by restic |
`mainframe` is 10.0.0.2 and runs the containers. `nas` is 10.0.0.3 and holds
`/nas/docs`, which mainframe mounts over NFS and restic backs up offsite.
## Things worth not rediscovering
Full detail is in `FINDINGS.md`. The short version:
- **SFTPGo ignores `If-Match` on `PUT`.** A bogus precondition still writes. Both
clients compare ETags themselves before pushing.
- **The ETag a `PUT` returns is not the file's own.** It is off by about a
millisecond. Read it back with a `HEAD` or every later sync sees a phantom
remote change.
- **`diff3` calls an identical change on both sides a conflict.** Ticking the
same box on both devices is ordinary here, so the laptop uses `git merge-file`,
which agrees with the Android merge about what a real conflict is.
- **A stale base silently invents conflicts.** A three way merge is only correct
if the base really is the last common ancestor, so every point where the two
sides are known to agree records that agreement.
- **Pointer coordinates cannot locate text in a scrolling `BasicTextField`.**
`getOffsetForPosition` wants layout coordinates and `pointerInput` gives
viewport ones. Let the field place the cursor and read that offset instead.
- **CrowdSec watches this vhost.** Never retry a 401, treat a 404 as "create
once" rather than something to poll, and never sync in the background. Bans
are four hours and IP scoped, with no whitelist for a phone on cellular.
## Requests this system will not grow
Not because they are bad ideas, but because each one trades away the property
that makes the whole thing survive: reminders, tags, a database, sub-second
sync, an account, or any format that `cat` cannot read. If a change would make
the notes unreadable without this software, it is the wrong change.
Regular → Executable
View File
Regular → Executable
View File