initial commit

This commit is contained in:
2026-05-17 15:59:43 -04:00
commit c08b3fe80d
9 changed files with 883 additions and 0 deletions

2
.env.example Normal file
View File

@@ -0,0 +1,2 @@
# copy to .env and change this
PHONE_CAPTURE_TOKEN=change-me-long-random-token

27
.gitignore vendored Normal file
View File

@@ -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

170
README.md Normal file
View File

@@ -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

31
agents.md Normal file
View File

@@ -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")

View File

@@ -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

130
docs/android-notes.md Normal file
View File

@@ -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.

177
docs/api.md Normal file
View File

@@ -0,0 +1,177 @@
# api contract
base url example:
```text
http://jeeves.mother:8765
```
all write endpoints require:
```text
authorization: bearer <phone_capture_token>
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 <token>
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.

117
docs/server-notes.md Normal file
View File

@@ -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 <first line> :tag:
```
note single-line:
```org
* note: <body> :tag:
```
note multiline:
```org
* note: <first line> :tag:
...
<body preserved below drawer>
```
## 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.

214
tasks.org Normal file
View File

@@ -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.