milestone 4: org header, logging, backup docs, mark all m4 tasks done

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-18 14:46:37 -04:00
parent 0799595b26
commit fd28a45cd7
3 changed files with 77 additions and 6 deletions

View File

@@ -157,6 +157,27 @@ synq/
5. add history screen and resend handling. 5. add history screen and resend handling.
6. polish launch speed and widget/share-target only after core path works. 6. polish launch speed and widget/share-target only after core path works.
## backup
### what to back up
the server writes to one directory (the `/data` volume). back up the whole thing:
```text
/data/synq.org ← the canonical org capture file
/data/capture.sqlite3 ← idempotency store (dedup ids)
/data/token.txt ← auto-generated token (if not using PHONE_CAPTURE_TOKEN env var)
```
on unraid the host path is whatever you mapped in compose, e.g. `/mnt/user/ben/synq/phone-capture`.
### restore behavior
- restore the `/data` volume to a new container and start it. the server will pick up where it left off.
- `synq.org` is append-only plain text — it is human-readable and recoverable even without the sqlite db.
- if `capture.sqlite3` is lost but `synq.org` is intact, the server will accept re-posted captures that were already in the org file (no dedup). the android app marks them synced either way (`already_seen` or `accepted`), so the only side effect is duplicate org entries for anything re-synced. restore the db from backup to avoid this.
- if `token.txt` is lost, delete it and let the server generate a new one on next start, then update the app settings.
## v2 parking lot ## v2 parking lot
- android share target - android share target

View File

@@ -82,6 +82,7 @@ async def check_token(request: Request) -> None:
auth = request.headers.get("authorization", "") auth = request.headers.get("authorization", "")
scheme, _, provided = auth.partition(" ") scheme, _, provided = auth.partition(" ")
if scheme.lower() != "bearer" or provided != expected: if scheme.lower() != "bearer" or provided != expected:
logger.warning("rejected request: invalid token from %s", request.client.host if request.client else "unknown")
raise HTTPException(status_code=401, detail="unauthorized") raise HTTPException(status_code=401, detail="unauthorized")
@@ -101,7 +102,10 @@ async def capture(payload: CaptureRequest, store: IdempotencyStore = Depends(get
lock = get_file_lock() lock = get_file_lock()
with lock: with lock:
Path(org_path).parent.mkdir(parents=True, exist_ok=True) org_file = Path(org_path)
org_file.parent.mkdir(parents=True, exist_ok=True)
if not org_file.exists():
org_file.write_text("#+title: synq captures\n#+startup: overview\n\n", encoding="utf-8")
with open(org_path, "a", encoding="utf-8") as f: with open(org_path, "a", encoding="utf-8") as f:
f.write(entry) f.write(entry)
store.mark_seen(payload.id, payload.created_at) store.mark_seen(payload.id, payload.created_at)
@@ -113,6 +117,8 @@ async def capture(payload: CaptureRequest, store: IdempotencyStore = Depends(get
@app.exception_handler(RequestValidationError) @app.exception_handler(RequestValidationError)
async def validation_exception_handler(request: Request, exc: RequestValidationError) -> JSONResponse: async def validation_exception_handler(request: Request, exc: RequestValidationError) -> JSONResponse:
errors = exc.errors() errors = exc.errors()
fields = [str(e.get("loc", "")) for e in errors]
logger.warning("rejected payload: validation failed fields=%s from=%s", fields, request.client.host if request.client else "unknown")
for err in errors: for err in errors:
if "body" in err.get("loc", ()): if "body" in err.get("loc", ()):
return JSONResponse(status_code=400, content={"detail": "body must not be empty"}) return JSONResponse(status_code=400, content={"detail": "body must not be empty"})

View File

@@ -303,30 +303,74 @@ SettingsScreen + SettingsViewModel from milestone 2. DataStore-backed. Default s
- datetime: [2026-05-18 Sun 18:00] - datetime: [2026-05-18 Sun 18:00]
* milestone 4: polish and hardening * milestone 4: polish and hardening
** TODO make launch path fast ** DONE make launch path fast
*** acceptance *** acceptance
- app opens directly to capture field. - app opens directly to capture field.
- no network call blocks launch. - no network call blocks launch.
- no history loading blocks text entry. - no history loading blocks text entry.
** TODO normalize tags *** notes
LaunchedEffect(Unit) { focusRequester.requestFocus() } fires immediately on composition. No network calls in MainActivity or CaptureScreen init path. History loads via Room Flow on a background coroutine — never blocks text entry.
*** evidence
- commit: 19b05a8
- tests: manual — cold launch, keyboard appears without any loading state
- datetime: [2026-05-18 Sun 19:00]
** DONE normalize tags
*** acceptance *** acceptance
- whitespace-separated and comma-separated tags both work. - whitespace-separated and comma-separated tags both work.
- invalid org tag chars are stripped or replaced. - invalid org tag chars are stripped or replaced.
- duplicate tags collapse. - duplicate tags collapse.
** TODO handle multiline notes well *** notes
Two-layer normalization: parseTags() in CaptureViewModel splits on [,\s]+ before storing to Room. normalize_tags() in server/app/org_writer.py lowercases, strips #, replaces spaces with _, removes non-[a-z0-9_] chars, deduplicates. 7 unit tests cover all cases.
*** evidence
- commit: e873a00 (server), 19b05a8 (android)
- tests: pytest tests/test_org_writer.py::TestNormalizeTags — 7 passed
- datetime: [2026-05-18 Sun 19:00]
** DONE fix org formatting
*** acceptance
- add `#+startup: overview` to synq.org
*** notes
Append writer in main.py checks if org file exists before first write. If missing, creates it with #+title and #+startup: overview header. Existing files are never modified.
*** evidence
- commit: (this commit)
- tests: manual — delete synq.org, post a capture, verify header present
- datetime: [2026-05-18 Sun 19:00]
** DONE handle multiline notes well
*** acceptance *** acceptance
- first line can become note heading if body is multiline. - first line can become note heading if body is multiline.
- full body is preserved under heading. - full body is preserved under heading.
- todo body remains usable as a single todo heading, with extra lines under it if present. - todo body remains usable as a single todo heading, with extra lines under it if present.
** TODO add basic logs *** notes
format_capture() in org_writer.py: multiline note uses first line as heading suffix ("* note: <first line>"), full body preserved below drawer. Single-line note uses "* note" heading, body below. Covered by test_note_multiline.
*** evidence
- commit: e873a00
- tests: pytest tests/test_org_writer.py::TestFormatCapture::test_note_multiline — passed
- datetime: [2026-05-17 Sat 17:00]
** DONE add basic logs
*** acceptance *** acceptance
- server logs accepted capture id. - server logs accepted capture id.
- server logs duplicate capture id. - server logs duplicate capture id.
- server logs rejected payload without dumping secrets. - server logs rejected payload without dumping secrets.
** TODO create backup note *** notes
logger.info for accepted and duplicate (id only, no body). logger.warning for invalid token (client IP only, no token value logged). logger.warning for validation errors (field names only, no body content).
*** evidence
- commit: (this commit)
- tests: manual — send bad token, send empty body, check uvicorn stdout
- datetime: [2026-05-18 Sun 19:00]
** DONE create backup note
*** acceptance *** acceptance
- readme documents which paths need backup. - readme documents which paths need backup.
- readme documents restore behavior. - readme documents restore behavior.
*** notes
Added "backup" section to README.md covering synq.org, capture.sqlite3, token.txt. Restore section documents behavior when each file is lost individually.
*** evidence
- commit: (this commit)
- tests: n/a
- datetime: [2026-05-18 Sun 19:00]
* milestone 5: optional v1.5 * milestone 5: optional v1.5
** TODO add android share target ** TODO add android share target