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:
@@ -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
|
||||
```
|
||||
Reference in New Issue
Block a user