Add plan of record for cross-device todo notes

Extends ~/.local/bin/todo to Android without turning it into an app. Notes stay
plain markdown in ~/docs/notes/todo; this repo holds the tooling.

Key finding: files.rcjohnstone.com already serves /home/connor/docs over
authenticated WebDAV, and sftpgo and syncthing mount the same host directory, so
today's note is already reachable at a public TLS URL. No new service, hostname,
Caddy block, or credential store is needed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NctNN59e573MxYdNWzNHUw
This commit is contained in:
Connor Johnstone
2026-09-09 17:53:32 -04:00
co-authored by Claude Opus 5
commit 275ecd78ac
+449
View File
@@ -0,0 +1,449 @@
# todo: daily notes, everywhere
Plan of record. Written 2026-09-09.
## 1. What this is
`~/.local/bin/todo` is a 15-line zsh script that has outlasted every notes app I
have tried. If today's file does not exist, copy the most recent one forward,
rewrite the date, drop into nvim. That is the whole idea and the whole appeal.
This project extends it to Android without making it into an app. Specifically:
- One tap from the Android homescreen into today's note, in a real text editor.
- Edits sync to the laptop without anyone thinking about sync.
- The notes stay plain markdown in `~/docs/notes/todo/`, readable and editable
with `cat` and `nvim` forever.
**The notes live in `~/docs/notes/todo/`. This repo (`~/docs/projects/todo/`)
holds the tooling: the rollover script, the systemd units, and the Android app.
The two never merge.**
## 2. Current state, as verified 2026-09-09
Facts established by inspection, not assumption.
### Laptop (`sctfw004`)
- 232 note files, `2024-07-29.md` through `2026-09-09.md`.
- 4 leftover Syncthing conflict files:
`2024-10-21`, `2024-11-12`, `2025-01-16`, `2025-01-17`.
- No syncthing binary installed. This laptop was never joined to the mesh.
- JDK 21.0.12 present. `ANDROID_HOME=/home/connorjohnstone/Android/Sdk` is
exported but the directory does not exist. 24 cores, 62 GB RAM, 1.6 TB free.
### Home server (`mainframe`, 10.0.0.2)
Everything runs as a rootful podman-compose stack defined in
`/home/connor/compose.yml`. The host's own `caddy` systemd unit is inactive and
is a decoy. The live one is the container `connor_caddy_1`.
Relevant mounts, both pointing at the same host directory:
| Service | Mount |
|---|---|
| sftpgo | `/home/connor/docs` -> `/srv/sftpgo/data/connor/docs` |
| syncthing | `/home/connor/docs` -> `/var/syncthing/Sync` |
- `/home/connor/docs/notes/todo/` has 168 files, newest `2026-03-30.md`. Stale
since March, fed by a machine that no longer exists.
- Laptop is a strict superset of mainframe. Nothing on mainframe is missing
locally. Only two shared files differ in content, `2024-10-21.md` and
`2025-01-16.md`, both dates that also carry a conflict file.
### The endpoint that makes this easy
`/home/connor/Caddyfile` (553 lines) splits public from private with a
`# ddns: public` comment marker above each site block. No marker means no public
DNS record, which means no HTTP-01 validation, which means no public cert and no
Certificate Transparency entry. The Caddyfile documents this at length. Do not
undermine it.
Two SFTPGo blocks, and the difference between them is the point:
```
# ddns: public
sftp.rcjohnstone.com { # admin UI, browser-facing
import authelia
...
reverse_proxy sftpgo:8080
}
# ddns: public
files.rcjohnstone.com { # WebDAV, native-app-facing
import secure_headers
import accesslog
reverse_proxy sftpgo:10080
}
```
`files.` deliberately has no `import authelia`. The Caddyfile's own comment on
the authelia snippet explains why:
> Identity gateway. Browser-facing services only. Native apps (Immich, Jellyfin,
> CalDAV, WebDAV, Subsonic, ntfy) cannot complete an interactive login, so they
> are covered by OIDC or by CrowdSec instead, not by bypass rules that would
> hollow this out.
Confirmed live from the LAN:
```
$ curl -I -X PROPFIND https://files.rcjohnstone.com/
HTTP/2 401
server: SFTPGo/2.7.5
www-authenticate: Basic realm="SFTPGo_2.7.5 WebDAV"
```
So `https://files.rcjohnstone.com/docs/notes/todo/2026-09-09.md` is already a
public, TLS-protected, authenticated URL for exactly the file we care about.
**No new service, hostname, Caddy block, DNS record, or credential store is
needed.** An Android app doing Basic auth against `files.` is the established
pattern in this stack, not an exception carved into it.
## 3. Architecture
```
/home/connor/docs/notes/todo/ <- canonical
|
+------------------+------------------+
| |
systemd timer 00:01 SFTPGo WebDAV :10080
(rollover + prune) |
files.rcjohnstone.com
|
+---------------+---------------+
| |
laptop: todo script Android app
(curl pull, nvim, push) (GET/PUT, offline cache)
```
Four decisions worth recording:
**Rollover moves to the server.** Previously the client created today's file.
Two clients doing that independently produce a guaranteed conflict, which is the
most likely origin of the four existing conflict files. A single writer on the
always-on box removes that failure mode by construction. Every client degrades
to "open the file named `$(date -I).md`", which is a much lower bar.
**Syncthing is dropped from this path.** It is a second file-access channel with
a second auth model and a second conflict convention, and it is currently broken
anyway. WebDAV already exists, is already public, and is already authenticated.
One protocol, one credential, one conflict strategy. Syncthing keeps running for
whatever else uses `docs/`, this just stops depending on it.
**Basic auth, existing `connor` SFTPGo user.** No second scoped user. Decided
deliberately: the added blast-radius protection was not judged worth the extra
account to manage.
**Native app, not a PWA.** PWAs feel clunky, and the soft-keyboard and scroll
behavior of a real `BasicTextField` is the entire experience here.
## 4. Rollover and the prune rule
The timer runs at 00:01 America/Louisville. It copies the most recent file to
today, rewrites line 1 to `# <today>`, and prunes completed work.
### Prune semantics
**Only whole top-level checkbox subtrees are eligible.** A top-level checkbox
item is one with no checkbox ancestor inside its section (headings reset scope).
Drop a top-level item together with its entire subtree if that item is `[x]`
and every checkbox in its subtree is also `[x]`. Otherwise keep the subtree
completely intact, finished children included.
Three consequences, all intended:
- **Finished children of an unfinished parent stay.** Grouping is how context
gets carried, so a `[x]` child under a `[ ]` parent is information, not cruft.
- **Non-checkbox bullets are never touched.** Only `- [ ]` and `- [x]` rows are
eligible. The prose bullets under `### DSA Election Research Tool` survive
untouched.
- **Headings are never removed**, even if a section empties out. Removing them
would mean making structural judgments the rule cannot justify.
Worked against the real `2026-09-08.md`:
```
- [ ] Add OpenID to any apps that I control KEPT (top-level, unchecked)
- [x] Runway KEPT (finished child, context)
- [ ] Shanty KEPT
- [x] Climb DROPPED (top-level, complete)
- [x] Have Claude re-do the news app DROPPED
```
### Validation before deployment
Run the prune across all 232 historical files and review the diff **before the
timer ever touches a live file**. This is the piece with the most semantic risk
and the recorded data to test it against already exists. Do not skip this.
## 5. Operational constraints: CrowdSec
`files.rcjohnstone.com` imports `accesslog`, so CrowdSec parses every request the
app makes. Bans are **4 hours, IP-scoped**. There is no whitelist covering a
phone on cellular. Collections in play: `crowdsecurity/caddy`, `http-cve`,
`base-http-scenarios`, `whitelist-good-actors`.
| Scenario | Trigger | Client rule |
|---|---|---|
| `http-generic-bf` | repeated 401/403 from one IP | **Never retry a 401.** Surface a credential error and stop. |
| `http-probing` | repeated 404s | **A 404 means create, not poll.** One `PUT`, never a retry loop. |
| `http-crawl-non_statics` | aggressive non-static fetching | Sync on foreground and on save only. No background polling. |
The 404 case is real, not theoretical: the app can legitimately ask for today's
file before the 00:01 timer has run, given clock skew or travel across a time
zone.
Non-issue, checked: there is no `sftpgo.json`, so SFTPGo runs on defaults, which
means its own defender is disabled and its rate limiter is inert. All ban risk
is CrowdSec's, and all of it is avoidable in client behavior.
Set a distinct `User-Agent` so these requests are legible in the access log.
OkHttp's default (`okhttp/x.y.z`) does not match `http-bad-user-agent`, but a
named agent makes debugging much easier.
## 6. Changes to `~/.local/bin/todo`
Current script:
```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 -
```
Three defects:
1. **`previous` can select a conflict file.** `ls` globs everything, so
`2024-10-21.sync-conflict-*.md` is a candidate. Ordering has been lucky so
far. Constrain the glob to `[0-9]*-[0-9]*-[0-9]*.md`.
2. **The `sed` is a global replace.** `s/$previous_date/$today/g` rewrites that
date anywhere in the body. Write "meeting moved to 2026-09-08" in a note and
it silently becomes 09-09 tomorrow. Only line 1 is wanted:
`sed -i "1s/.*/# $today/"`.
3. **`&& cd -` is dead.** The script runs in its own process, so neither `cd`
affects the calling shell.
Then add WebDAV pull before nvim and push after. If the pull fails, edit the
local copy and push later. Rollover is no longer the script's job, but keep a
local fallback for the case where the server is unreachable and today's file
does not exist yet.
## 7. The Android app
One screen. `BasicTextField`, OkHttp, `GET` and `PUT` against
`https://files.rcjohnstone.com/docs/notes/todo/<date>.md`. Credentials in
`EncryptedSharedPreferences`.
### Editing
**Source-mode highlighting: markers stay visible and get styled in place.**
`**bold**` renders bold with the asterisks still showing. This is a deliberate
call. Hiding markers requires a non-identity `OffsetMapping`, which is fiddly
and makes the cursor jump in ways that feel broken. Keeping every character
means offset mapping is identity, so cursor and selection math is simply
correct. It also stays honest plain text, which is the entire point of the
system.
Implemented as a `VisualTransformation` producing an `AnnotatedString`:
| Syntax | Rendering |
|---|---|
| `#` through `####` | bold, scaled by level |
| `**bold**` | bold |
| `*italic*`, `_italic_` | italic |
| `` `code` `` | monospace, tinted background |
| `~single~` and `~~double~~` | strikethrough (single-tilde is what I actually write, double is what CommonMark specifies, support both) |
| `[text](url)` | colored |
| `- [x] ...` | whole line dimmed and struck |
**Tap-to-toggle.** `onTextLayout` yields a `TextLayoutResult`, and
`pointerInput` plus `getOffsetForPosition` turns a tap into a character offset.
Flip the checkbox only when that offset lands **inside the `[ ]`/`[x]` span
itself**, not anywhere on the line. A tap anywhere else places the cursor like a
normal text field, so the gesture never fights ordinary editing.
The transformation runs on every keystroke across the whole document. Files are
around 4 KB so this is fine, but memoize on a text hash anyway.
### Sync and offline
The local file is what the editor reads and writes, always. The app opens
instantly and works with no signal. Alongside it, keep the pristine copy from
the last sync and its ETag.
On foreground and on save: `GET`, compare ETags. Match means `PUT`. Mismatch
means a three-way merge from base, mine, and theirs, which for checkbox toggles
on distinct lines resolves cleanly in essentially every real case. A genuine
same-line collision writes a `.sync-conflict-` sibling, matching the convention
already present in the directory.
**To verify in phase 1:** SFTPGo's WebDAV is `golang.org/x/net/webdav`
underneath. It returns ETags, but it is unclear whether it honors `If-Match` on
`PUT`. If it does not, fall back to HEAD-compare-then-`PUT`. That leaves a race
window of milliseconds for a single person with one phone, which is acceptable.
## 8. Android development primer
Requested explicitly. This is the part of the stack I have never touched, and
the plan is to actually understand it rather than let it stay a black box.
### The build
An **APK** is a zip: compiled Dalvik bytecode (`classes.dex`), resources, the
manifest, and a signature. An **AAB** is what Play wants instead, but for
sideloading via `adb` an APK is what you need.
**Gradle** is the build tool and **AGP** (Android Gradle Plugin) is the plugin
that teaches Gradle about Android. Version pairs matter: AGP 8.x needs JDK
17 or newer, and JDK 21 is present, so that is fine.
The **SDK** at `$ANDROID_HOME` is three things that get confused constantly:
- `platforms/android-NN/`, the compile-time API stubs, one per API level.
- `build-tools/x.y.z/`, the tools that make an APK (`aapt2`, `d8`, `apksigner`).
- `platform-tools/`, the device tools, most importantly `adb`.
You do **not** need Android Studio to build. `cmdline-tools` plus `sdkmanager`
is enough, and that is the plan here. Studio is still genuinely useful for
Layout Inspector and for reading `logcat` comfortably, so it may be worth
installing later even if the build never depends on it.
### The three SDK versions
These are different on purpose and mixing them up is the classic beginner error:
- **`compileSdk`** is what you compile against. Use the newest. It only decides
which APIs the compiler will let you name.
- **`minSdk`** is the oldest Android that will install the app. Lower means more
devices and more compatibility shims. For a personal app, set it to whatever
the phone actually runs.
- **`targetSdk`** is a promise: "I have tested against this API level's
behavior changes." Android applies compatibility behavior for anything older.
This is the one with real semantics attached.
### The manifest and the launcher icon
`AndroidManifest.xml` declares components and permissions. A homescreen icon is
not a setting, it is an `<activity>` carrying an `<intent-filter>` with
`MAIN` plus `LAUNCHER`. That is literally what "an app on the homescreen" means.
`INTERNET` is a **normal** permission: declared in the manifest, granted at
install, no runtime prompt. Because our file lives on the server and the cache
lives in app-private internal storage, **no storage permission is needed at
all**, and scoped storage and SAF never enter the picture. That is a large
category of Android pain avoided by architecture rather than by fighting it.
### Lifecycle, and why the sync design depends on it
An Activity moves through `onCreate` / `onStart` / `onResume` / `onPause` /
`onStop` / `onDestroy`. Android can and does destroy your process whenever it
likes. Two direct consequences for this app:
- "Sync on foreground" means `onResume` (or a lifecycle-aware coroutine scope),
which is the hook that fires when the user returns to the app.
- Never treat in-memory state as durable. Text must be persisted to the local
file as it changes, because the process can vanish without warning.
### Compose
**Jetpack Compose** is the declarative UI toolkit that replaced XML layouts and
`findViewById`. You write functions annotated `@Composable` that describe the UI
as a function of state. When state changes, Compose re-invokes the affected
functions. That re-invocation is **recomposition**.
Two ideas do most of the work:
- `remember { mutableStateOf(x) }` creates state that survives recomposition and
triggers it on write.
- **State hoisting**: a composable takes its value plus an `onValueChange`
callback rather than owning state internally. This is why `BasicTextField`
takes `value` and `onValueChange`, and why the document lives above it.
### Coroutines
Network I/O on the main thread throws `NetworkOnMainThreadException`. Kotlin
**coroutines** are the answer: `suspend` functions that can pause without
blocking a thread. `viewModelScope.launch { ... }` for work that should die with
the screen, `Dispatchers.IO` for blocking I/O. OkHttp has suspend-friendly
wrappers.
### Signing, and the one irreversible mistake
Every APK must be signed. Debug builds use an auto-generated debug keystore and
that is fine for sideloading. A release build needs a keystore you create.
**Back that keystore up.** Android identifies an app by package name plus
signing key. Lose the key and you can never upgrade the installed app again, you
can only uninstall and reinstall, which discards its data. This is the one
genuinely unrecoverable Android mistake, and it is worth putting the keystore
somewhere durable on day one. Not in this repo, since this repo will be
git-tracked.
### Getting it onto the phone
`adb install -r app-debug.apk` over USB. Enable Developer Options (tap Build
Number seven times in Settings), then USB debugging. **Wireless debugging** also
works well and avoids the cable: pair once with `adb pair`, then
`adb connect <ip>:<port>`.
Test on the real phone rather than an emulator. The emulator is slow, and every
interesting question here (soft keyboard behavior, scroll physics, tap targets,
what the text actually looks like) is a question about the real device.
## 9. Phases
**Phase 0. Reconcile.**
Verify the authenticated WebDAV path end to end with real credentials. Resolve
the two divergent files (`2024-10-21.md`, `2025-01-16.md`) against their conflict
files, then delete all four stragglers. Push laptop to server, laptop wins,
since it is a strict superset. Initialize this repo.
**Phase 1. Server rollover.**
Write the prune script. Run it across all 232 historical files and review the
diff. Only then install the systemd timer on mainframe. Also settle the
`If-Match` question against the live SFTPGo.
**Phase 2. Laptop client.**
Three fixes to `todo`, plus curl pull and push. At the end of this phase the
laptop half is done and the notes are live on the server again.
**Phase 3. Android, ugly version.**
Install `cmdline-tools` into the existing empty `~/Android/Sdk`. Build a
deliberately plain app: fetch, edit, save, launcher icon, nothing else. Get it
on the phone early, before there is anything worth throwing away. This is also
the phase where the primer above stops being theory.
**Phase 4. The editor.**
Syntax highlighting and tap-to-toggle.
**Phase 5. Offline.**
Local cache, ETag tracking, three-way merge, conflict siblings.
Phases 3 through 5 build on the laptop, not on mainframe. The build is small,
and the artifact has to reach the phone by `adb` from whatever machine is next
to it.
## 10. Open questions
- Phone's actual Android version, to set `minSdk`.
- Where the release keystore should live. Not in this repo.
- Whether the laptop should keep a Syncthing membership for the rest of
`docs/`, independent of this project.
## 11. Repo layout (proposed)
```
~/docs/projects/todo/
PLAN.md this file
rollover/ prune script + systemd unit and timer
client/ revised todo script
android/ the app
```