Compare commits
2 Commits
df9485bd98
...
65cb8ee2d5
| Author | SHA1 | Date | |
|---|---|---|---|
| 65cb8ee2d5 | |||
| fd28a45cd7 |
21
README.md
21
README.md
@@ -156,6 +156,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
|
||||||
|
|||||||
@@ -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"})
|
||||||
|
|||||||
48
tasks.org
48
tasks.org
@@ -330,38 +330,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.
|
||||||
|
*** 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
|
*** 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.
|
||||||
|
*** 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
|
*** acceptance
|
||||||
- add `#+startup: overview` to synq.org
|
- 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
|
*** 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.
|
||||||
|
*** 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]
|
||||||
|
|
||||||
** TODO add basic logs
|
** 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.
|
||||||
|
*** 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
|
*** 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 settings for sync frequency
|
** TODO add settings for sync frequency
|
||||||
|
|||||||
Reference in New Issue
Block a user