diff --git a/README.md b/README.md index 1e2a46e..a76c198 100644 --- a/README.md +++ b/README.md @@ -156,6 +156,27 @@ synq/ 5. add history screen and resend handling. 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 - android share target diff --git a/server/app/main.py b/server/app/main.py index b37aa48..5ea8f09 100644 --- a/server/app/main.py +++ b/server/app/main.py @@ -82,6 +82,7 @@ async def check_token(request: Request) -> None: auth = request.headers.get("authorization", "") scheme, _, provided = auth.partition(" ") 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") @@ -101,7 +102,10 @@ async def capture(payload: CaptureRequest, store: IdempotencyStore = Depends(get lock = get_file_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: f.write(entry) 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) async def validation_exception_handler(request: Request, exc: RequestValidationError) -> JSONResponse: 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: if "body" in err.get("loc", ()): return JSONResponse(status_code=400, content={"detail": "body must not be empty"}) diff --git a/tasks.org b/tasks.org index d6cbf9e..d4086a4 100644 --- a/tasks.org +++ b/tasks.org @@ -330,38 +330,74 @@ SettingsScreen + SettingsViewModel from milestone 2. DataStore-backed. Default s - datetime: [2026-05-18 Sun 18:00] * milestone 4: polish and hardening -** TODO make launch path fast +** DONE make launch path fast *** acceptance - app opens directly to capture field. - no network call blocks launch. - no history loading blocks text entry. +*** 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] -** TODO normalize tags +** DONE normalize tags *** acceptance - whitespace-separated and comma-separated tags both work. - invalid org tag chars are stripped or replaced. - duplicate tags collapse. +*** 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] -** TODO fix org formatting +** 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: fd28a45 +- tests: manual — delete synq.org, post a capture, verify header present +- datetime: [2026-05-18 Sun 19:00] -** TODO handle multiline notes well +** DONE 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. +*** notes +format_capture() in org_writer.py: multiline note uses first line as heading suffix ("* note: "), 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] -** TODO add basic logs +** DONE add basic logs *** acceptance - server logs accepted capture id. - server logs duplicate capture id. - server logs rejected payload without dumping secrets. +*** 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: fd28a45 +- tests: manual — send bad token, send empty body, check uvicorn stdout +- datetime: [2026-05-18 Sun 19:00] -** TODO create backup note +** DONE create backup note *** acceptance - readme documents which paths need backup. - 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 ** TODO add settings for sync frequency