commit c08b3fe80dc0dfeca73840c1a88d3e0d7c74b4e4 Author: eulaly Date: Sun May 17 15:59:43 2026 -0400 initial commit diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..26341a9 --- /dev/null +++ b/.env.example @@ -0,0 +1,2 @@ +# copy to .env and change this +PHONE_CAPTURE_TOKEN=change-me-long-random-token diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b444d40 --- /dev/null +++ b/.gitignore @@ -0,0 +1,27 @@ +.env +*.sqlite +*.sqlite3 +__pycache__/ +.pytest_cache/ +.venv/ +venv/ +dist/ +build/ +*.pyc + +# android +android/.gradle/ +android/local.properties +android/**/build/ +android/captures/ +*.apk +*.aab + +# ide +.idea/ +.vscode/ +*.iml + +# os +.DS_Store +Thumbs.db diff --git a/README.md b/README.md new file mode 100644 index 0000000..7207d0d --- /dev/null +++ b/README.md @@ -0,0 +1,170 @@ +# synq, a tiny android-to-org capture system. + +open app. +type, optionally mark as todo, optionally add tags, save. +if home server accessible, post unsynced captures to lan-only service and appended to `phone.org`. + +## non-goals + +- no org parser on android +- no agenda on android +- no sync provider dependency +- no nextcloud file editing +- no conflict resolution +- no public internet exposure +- no ai tagging in v1 +- no account system in v1 + +## architecture + +```text +android app + kotlin + jetpack compose + room sqlite local queue + workmanager + manual sync + okhttp/retrofit client + +home server + docker container on jeeves/unraid + python + fastapi + sqlite idempotency store + append-only org writer + +flow + user saves capture locally + app marks it pending + sync runs when server is reachable + app posts pending captures to fastapi + server validates, dedupes by id, appends to phone.org + app marks capture synced +``` + +## v1 behavior + +the app launches into a focused text box. the user can type immediately. + +controls: + +- note/todo toggle +- optional tags field +- save +- save and close +- sync now +- small history view showing pending, synced, and failed entries + +each capture has: + +- stable client-generated id +- created timestamp with timezone +- kind: `note` or `todo` +- body text +- tags +- device name +- sync status +- optional last error + +## org output + +todo: + +```org +* TODO buy printer paper :home:errands: +:PROPERTIES: +:CREATED: [2026-05-17 sun 14:31] +:SOURCE: android +:ID: phone-20260517-143122-a8f2 +:END: +``` + +note: + +```org +* note :retcon: +:PROPERTIES: +:CREATED: [2026-05-17 sun 14:33] +:SOURCE: android +:ID: phone-20260517-143322-b91c +:END: + +mobile capture should stay dumb and append-only. +``` + +## server paths + +default container paths: + +```text +/data/synq.org +/data/capture.sqlite3 +/data/rejected.log +``` + +recommended unraid host mapping: + +```text +/mnt/user/synq/phone-capture:/data +``` + +or map `/data/synq.org` directly to wherever the real org file lives. prefer a dedicated capture file first; emacs can include it in agenda later. + +## security model + +v1 is lan-only. bind the service to the host lan and do not expose it through swag/cloudflare/public dns. + +minimum useful controls: + +- shared bearer token in app and server env +- server only accepts json +- server rejects empty body +- server dedupes ids +- server writes append-only org +- server never edits or parses existing org +- docker volume is backed up + +## repo layout + +suggested monorepo: + +```text +synq/ + android/ + app/ + server/ + app/ + main.py + models.py + org_writer.py + store.py + tests/ + Dockerfile + pyproject.toml + docs/ + api.md + android-notes.md + server-notes.md + docker-compose.example.yml + .env.example + tasks.org + README.md +``` + +## build order + +1. implement server first using curl tests. +2. implement android local capture with room. +3. implement manual sync. +4. add workmanager opportunistic sync. +5. add history screen and resend handling. +6. polish launch speed and widget/share-target only after core path works. + +## v2 parking lot + +- android share target +- quick settings tile or widget +- tag chips from recent tags +- configurable default tag +- edit unsynced entries only +- multi-device capture +- wireguard-aware sync +- local export/import +- optional emacs ingest helpers diff --git a/agents.md b/agents.md new file mode 100644 index 0000000..fc88d00 --- /dev/null +++ b/agents.md @@ -0,0 +1,31 @@ +# claude-code brief + +build the phone capture project described in this repo. + +priority order: + +1. server mvp +2. server tests +3. docker packaging +4. android local capture +5. android manual sync +6. android opportunistic sync +7. history/settings polish + +hard constraints: + +- do not build mobile org-mode. +- do not parse org files on android. +- do not write org files from android. +- do not add nextcloud or file-sync logic. +- do not expose the service publicly. +- do not add accounts/oauth. +- do not block app launch on network checks. +- keep the server append-only and idempotent. +- keep org formatting in a pure/tested function. + +start by implementing the fastapi service according to `docs/api.md` and `docs/server-notes.md`. after the server passes tests, create the android app according to `docs/android-notes.md`. + +before adding any v2 features, confirm all acceptance criteria in `tasks.org` milestone 1-3 are done. + +project name is "synq", use this as basic title/prefix as necessary (not "phone-sync") diff --git a/docker-compose.example.yml b/docker-compose.example.yml new file mode 100644 index 0000000..a65309d --- /dev/null +++ b/docker-compose.example.yml @@ -0,0 +1,15 @@ +services: + phone-capture: + build: ./server + container_name: phone-capture + restart: unless-stopped + ports: + - "8765:8765" + environment: + PHONE_CAPTURE_TOKEN: "${PHONE_CAPTURE_TOKEN}" + PHONE_CAPTURE_ORG_PATH: "/data/phone.org" + PHONE_CAPTURE_DB_PATH: "/data/capture.sqlite3" + PHONE_CAPTURE_HOST: "0.0.0.0" + PHONE_CAPTURE_PORT: "8765" + volumes: + - /mnt/user/org/phone-capture:/data diff --git a/docs/android-notes.md b/docs/android-notes.md new file mode 100644 index 0000000..0cb84b9 --- /dev/null +++ b/docs/android-notes.md @@ -0,0 +1,130 @@ +# android notes + +## stack + +- kotlin +- jetpack compose +- room +- okhttp or retrofit +- workmanager +- kotlinx serialization or moshi + +## main screens + +### capture screen + +default screen. should open fast and focus text immediately. + +fields: + +- body text +- todo checkbox or segmented note/todo control +- tags text field +- save +- save and close +- sync now + +do not run network checks before user can type. + +### history screen + +small list of recent captures. + +show: + +- created timestamp +- first line/body preview +- kind +- tags +- status: pending/synced/failed +- last error when failed +- retry action for pending/failed + +### settings screen + +minimum fields: + +- server base url +- bearer token +- device label + +defaults: + +```text +server base url: http://jeeves.mother:8765 +device label: android +``` + +## local database sketch + +capture entity: + +```kotlin +data class CaptureEntity( + val id: String, + val createdAt: String, + val kind: String, + val body: String, + val tagsJson: String, + val device: String, + val status: String, + val syncedAt: String?, + val lastError: String? +) +``` + +status values: + +```text +pending +syncing +synced +failed +``` + +## save behavior + +- trim body. +- if empty, do not save. +- generate id. +- persist row with status pending. +- clear input. +- optionally close if user used save and close. + +## sync behavior + +manual sync and workmanager should share the same sync service. + +algorithm: + +```text +load pending + failed captures +for each capture: + post to server + if accepted or already_seen: + mark synced + else: + mark failed with last_error +``` + +do not delete synced captures in v1. keep local history. + +## workmanager + +constraints: + +- network available + +periodic behavior: + +- check `/health` +- if healthy, sync pending/failed +- if not healthy, exit quietly + +do not try to perfectly detect home network in v1. reachability is enough. if it can hit `jeeves.mother`, it is home/vpn enough. + +## launch speed + +hard rule: no network call before text entry is usable. + +load settings and db async. the first rendered thing should be a focused text box. diff --git a/docs/api.md b/docs/api.md new file mode 100644 index 0000000..ea30638 --- /dev/null +++ b/docs/api.md @@ -0,0 +1,177 @@ +# api contract + +base url example: + +```text +http://jeeves.mother:8765 +``` + +all write endpoints require: + +```text +authorization: bearer +content-type: application/json +``` + +## get /health + +request: + +```http +GET /health +``` + +response: + +```json +{ + "ok": true, + "service": "synq", + "version": "0.1.0" +} +``` + +## post /capture + +request: + +```http +POST /capture +Authorization: Bearer +Content-Type: application/json +``` + +body: + +```json +{ + "id": "phone-20260517-143122-a8f2", + "created_at": "2026-05-17T14:31:22-04:00", + "kind": "todo", + "body": "buy printer paper", + "tags": ["home", "errands"], + "device": "android" +} +``` + +field rules: + +- `id`: required, stable, unique per capture. +- `created_at`: required iso-8601 datetime with timezone. +- `kind`: required, either `note` or `todo`. +- `body`: required, trimmed, non-empty. +- `tags`: required array, may be empty. +- `device`: required string, e.g. `android`. + +success, newly accepted: + +```json +{ + "ok": true, + "status": "accepted", + "id": "phone-20260517-143122-a8f2" +} +``` + +success, duplicate/idempotent retry: + +```json +{ + "ok": true, + "status": "already_seen", + "id": "phone-20260517-143122-a8f2" +} +``` + +invalid token: + +```json +{ + "detail": "unauthorized" +} +``` + +invalid payload: + +```json +{ + "detail": "body must not be empty" +} +``` + +## org formatting rules + +todo capture: + +```org +* TODO buy printer paper :home:errands: +:PROPERTIES: +:CREATED: [2026-05-17 sun 14:31] +:SOURCE: android +:ID: phone-20260517-143122-a8f2 +:END: +``` + +note capture: + +```org +* note :retcon: +:PROPERTIES: +:CREATED: [2026-05-17 sun 14:33] +:SOURCE: android +:ID: phone-20260517-143322-b91c +:END: + +mobile capture should stay dumb and append-only. +``` + +multiline note capture: + +input body: + +```text +retcon capture idea + +phone should produce records, not edit org files. +``` + +output: + +```org +* note: retcon capture idea :retcon: +:PROPERTIES: +:CREATED: [2026-05-17 sun 14:33] +:SOURCE: android +:ID: phone-20260517-143322-b91c +:END: + +retcon capture idea + +phone should produce records, not edit org files. +``` + +suggested behavior: use first line as heading suffix for notes when helpful, but preserve full body below the drawer. + +## idempotency + +server must never append the same `id` twice. + +recommended implementation: + +1. begin sqlite transaction. +2. check whether id already exists. +3. if exists, return `already_seen`. +4. append org entry. +5. insert id into sqlite. +6. commit. + +if append succeeds but sqlite insert fails, log loudly. safest implementation may insert first inside a transaction and mark appended after write, but keep the code simple and test the duplicate path. + +## status codes + +- 200: accepted or already seen. +- 400: malformed request or invalid capture. +- 401: missing/invalid token. +- 500: server failed to append or persist state. + +android should treat `accepted` and `already_seen` as synced. diff --git a/docs/server-notes.md b/docs/server-notes.md new file mode 100644 index 0000000..97d7d28 --- /dev/null +++ b/docs/server-notes.md @@ -0,0 +1,117 @@ +# server notes + +## stack + +- python +- fastapi +- pydantic +- sqlite +- uvicorn +- pytest +- docker + +## env vars + +```text +PHONE_CAPTURE_TOKEN=change-me +PHONE_CAPTURE_ORG_PATH=/data/synq.org +PHONE_CAPTURE_DB_PATH=/data/capture.sqlite3 +PHONE_CAPTURE_HOST=0.0.0.0 +PHONE_CAPTURE_PORT=8765 +``` + +## endpoints + +- `GET /health` +- `POST /capture` + +see `docs/api.md`. + +## file writing + +server appends to one org file. it does not parse or rewrite existing org content. + +recommended append behavior: + +- format entry into string ending with newline. +- acquire a process-local lock for append. +- open file in append mode with utf-8. +- write exactly one entry. +- flush/close. +- store capture id in sqlite. + +## tag normalization + +org heading tags must look like: + +```org +:home:errands: +``` + +normalization: + +- lowercase +- trim whitespace +- strip leading `#` +- replace spaces with `_` +- remove chars not matching `[a-z0-9_@#%]` or choose a stricter set +- collapse duplicates +- omit empty tags + +## heading rules + +todo: + +```org +* TODO :tag: +``` + +note single-line: + +```org +* note: :tag: +``` + +note multiline: + +```org +* note: :tag: +... + +``` + +## auth + +use one shared bearer token. + +do not add oauth, users, sessions, or accounts. + +## docker + +bind port on lan only. do not publish via swag/cloudflare. + +example: + +```yaml +services: + synq: + build: ./server + ports: + - "8765:8765" + environment: + PHONE_CAPTURE_TOKEN: "${PHONE_CAPTURE_TOKEN}" + PHONE_CAPTURE_ORG_PATH: "/data/synq.org" + PHONE_CAPTURE_DB_PATH: "/data/capture.sqlite3" + volumes: + - /mnt/user/mac/synq/phone-capture:/data +``` + +## tests to write first + +- org formatter: todo with tags. +- org formatter: note with no tags. +- org formatter: multiline note. +- api: valid post appends once. +- api: duplicate post appends once. +- api: missing token rejected. +- api: empty body rejected. diff --git a/tasks.org b/tasks.org new file mode 100644 index 0000000..01bfbee --- /dev/null +++ b/tasks.org @@ -0,0 +1,214 @@ +#+title: phone capture tasks +#+startup: overview +#+todo: TODO(t!) NEXT(n!) HOLD(h@!) | DONE(d!) CANX(c@!) + +* goal +build a tiny android-to-org capture system. + +the app must be instant-feeling, capture-only, and boring. the phone stores notes locally, then posts them to a lan-only server. the server appends validated entries to a central org file. + +* decisions +- android-only native app. +- kotlin + jetpack compose. +- room sqlite for local queue/history. +- okhttp or retrofit for http. +- workmanager for opportunistic sync. +- python fastapi listener in docker. +- server owns all writes to the canonical org file. +- phone never edits org files directly. +- server dedupes by capture id. +- no nextcloud/file-sync dependency. +- no org parser on phone. +- no public exposure. + +* acceptance criteria +- opening the app focuses the text field automatically. +- saving a note works offline and returns the user to an empty entry state. +- captures include local created timestamp with timezone. +- user can mark capture as todo. +- user can add optional tags. +- manual sync posts all pending captures when server is reachable. +- server appends valid captures to `phone.org`. +- duplicate posts do not create duplicate org entries. +- failed sync attempts remain visible and retryable. +- app shows at least pending/synced/failed history. +- service can run in docker on jeeves/unraid. +- service has a `/health` endpoint. +- service rejects missing/invalid auth token. +- service rejects empty capture body. +- service has tests for org formatting and duplicate handling. + +* milestone 0: repo setup +** TODO create monorepo skeleton +*** acceptance +- `android/`, `server/`, and `docs/` directories exist. +- root `README.md`, `tasks.org`, `.env.example`, and `docker-compose.example.yml` exist. +** TODO add root gitignore +*** acceptance +- ignores android build outputs. +- ignores python virtualenv/cache. +- ignores sqlite db files. +- ignores local env files. +- does not ignore docs or source files. +** TODO document v1 scope in README +*** acceptance +- states capture-only scope. +- states non-goals. +- states architecture and build order. + +* milestone 1: server mvp +** TODO create fastapi app +*** acceptance +- `GET /health` returns 200 with simple json. +- `POST /capture` accepts a valid capture payload. +- auth token is required for `POST /capture`. +** TODO define pydantic capture model +*** acceptance +- requires id, created_at, kind, body, tags, and device. +- kind is constrained to note/todo. +- body is trimmed and must not be empty. +- tags are normalized before formatting. +** TODO implement org formatter +*** acceptance +- todo captures produce `* TODO ...`. +- note captures produce `* note ...`. +- org property drawer includes created, source, and id. +- tags are emitted as org heading tags. +- multiline note body is preserved. +- tests cover todo, note, tags, empty tags, and multiline body. +** TODO implement sqlite idempotency store +*** acceptance +- accepted capture ids are stored. +- repeated id returns accepted/already-seen without appending. +- db path is configurable. +** TODO implement append writer +*** acceptance +- appends to configured org path. +- creates file if missing. +- writes utf-8. +- appends exactly one entry per new capture. +- uses file lock or equivalent simple concurrency guard. +** TODO add server tests +*** acceptance +- health endpoint test. +- valid capture append test. +- duplicate capture test. +- invalid token test. +- empty body rejection test. +** TODO containerize server +*** acceptance +- dockerfile builds server image. +- compose example maps `/data`. +- env vars configure token, org file, and sqlite path. +- container starts and exposes configured port. + +* milestone 2: android local capture +** TODO create android project +*** acceptance +- kotlin android app builds. +- jetpack compose enabled. +- min sdk is reasonable for current personal device. +- app id is stable, e.g. `me.hgsky.phonecapture`. +** TODO build capture screen +*** acceptance +- app opens to multiline text box. +- keyboard opens automatically. +- note/todo control exists. +- tags field exists. +- save button exists. +- save and close affordance exists. +** TODO implement local room database +*** acceptance +- capture entity has id, created_at, kind, body, tags, device, status, synced_at, and last_error. +- saving creates pending row. +- pending captures survive app restart. +** TODO implement capture id generation +*** acceptance +- id format is stable and readable, e.g. `phone-yyyymmdd-hhmmss-rand`. +- ids are generated client-side. +- tests or simple assertions prevent blank/duplicate ids in normal flow. +** TODO implement basic history screen +*** acceptance +- lists recent captures. +- shows pending/synced/failed status. +- failed row shows last error. +- user can retry failed/pending sync. + +* milestone 3: android sync +** TODO implement api client +*** acceptance +- base url configurable in app settings or build config. +- bearer token configurable in app settings or build config. +- client can call `/health`. +- client can post capture payload. +** TODO implement manual sync +*** acceptance +- sync now posts all pending captures. +- successful posts mark rows synced. +- already-seen response marks row synced. +- failures retain pending/failed status and last error. +** TODO implement opportunistic workmanager sync +*** acceptance +- periodic sync is registered. +- sync only runs when network is available. +- worker first checks `/health`. +- worker does not block capture speed. +** TODO add simple server reachability settings +*** acceptance +- default url can be set to `http://jeeves.mother:8765`. +- user can change server url. +- user can change token. +- invalid settings do not crash app. + +* milestone 4: polish and hardening +** TODO make launch path fast +*** acceptance +- app opens directly to capture field. +- no network call blocks launch. +- no history loading blocks text entry. +** TODO normalize tags +*** acceptance +- whitespace-separated and comma-separated tags both work. +- invalid org tag chars are stripped or replaced. +- duplicate tags collapse. +** TODO handle multiline notes well +*** acceptance +- first line can become note heading if body is multiline. +- full body is preserved under heading. +- todo body remains usable as a single todo heading, with extra lines under it if present. +** TODO add basic logs +*** acceptance +- server logs accepted capture id. +- server logs duplicate capture id. +- server logs rejected payload without dumping secrets. +** TODO create backup note +*** acceptance +- readme documents which paths need backup. +- readme documents restore behavior. + +* milestone 5: optional v1.5 +** TODO add android share target +*** acceptance +- sharing text to app opens prefilled capture. +- user can save as note or todo. +** TODO add home screen quick capture widget +*** acceptance +- widget opens capture screen quickly. +- does not require sync to work. +** TODO add recent tag chips +*** acceptance +- app shows recent tags. +- tapping chip adds/removes tag. +** TODO add edit-before-sync +*** acceptance +- pending/failed captures can be edited. +- synced captures are read-only unless explicitly duplicated. + +* implementation notes for coding agents +- keep the server small and testable. +- keep org formatting in one pure function. +- do not add auth flows, accounts, or public hosting. +- do not add org parsing. +- do not write to org files from android. +- prefer boring explicit settings over clever discovery. +- if a design choice risks sync conflicts, do the simpler append-only thing.