Files
Connor JohnstoneandClaude Opus 5 19049fc40c 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
2026-09-09 21:04:05 -04:00

9.2 KiB

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:

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.

# 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.

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.