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:
co-authored by
Claude Opus 5
parent
2bf910757a
commit
19049fc40c
@@ -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
Regular → Executable
Reference in New Issue
Block a user