# 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 `# `, 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/.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 `` carrying an `` 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 :`. 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 ```