implement server mvp: fastapi app, org formatter, sqlite store, tests, dockerfile

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-17 16:16:30 -04:00
parent 5f387dfb4a
commit e873a0055f
10 changed files with 499 additions and 0 deletions

0
server/app/__init__.py Normal file
View File

85
server/app/main.py Normal file
View File

@@ -0,0 +1,85 @@
import logging
import os
from pathlib import Path
from fastapi import Depends, FastAPI, HTTPException, Request
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from .models import CaptureRequest, CaptureResponse, HealthResponse
from .org_writer import format_capture
from .store import IdempotencyStore, get_file_lock
logger = logging.getLogger("synq")
app = FastAPI(title="synq", version="0.1.0")
_store: IdempotencyStore | None = None
def get_store() -> IdempotencyStore:
global _store
if _store is None:
db_path = os.environ.get("PHONE_CAPTURE_DB_PATH", "/data/capture.sqlite3")
_store = IdempotencyStore(db_path)
return _store
def _org_path() -> str:
return os.environ.get("PHONE_CAPTURE_ORG_PATH", "/data/synq.org")
def _token() -> str:
return os.environ.get("PHONE_CAPTURE_TOKEN", "")
async def check_token(request: Request) -> None:
expected = _token()
if not expected:
raise HTTPException(status_code=500, detail="server token not configured")
auth = request.headers.get("authorization", "")
scheme, _, provided = auth.partition(" ")
if scheme.lower() != "bearer" or provided != expected:
raise HTTPException(status_code=401, detail="unauthorized")
@app.get("/health", response_model=HealthResponse)
async def health() -> HealthResponse:
return HealthResponse(ok=True, service="synq", version="0.1.0")
@app.post("/capture", response_model=CaptureResponse, dependencies=[Depends(check_token)])
async def capture(payload: CaptureRequest, store: IdempotencyStore = Depends(get_store)) -> CaptureResponse:
if store.already_seen(payload.id):
logger.info("duplicate capture id=%s", payload.id)
return CaptureResponse(ok=True, status="already_seen", id=payload.id)
entry = format_capture(payload)
org_path = _org_path()
lock = get_file_lock()
with lock:
Path(org_path).parent.mkdir(parents=True, exist_ok=True)
with open(org_path, "a", encoding="utf-8") as f:
f.write(entry)
store.mark_seen(payload.id, payload.created_at)
logger.info("accepted capture id=%s", payload.id)
return CaptureResponse(ok=True, status="accepted", id=payload.id)
@app.exception_handler(RequestValidationError)
async def validation_exception_handler(request: Request, exc: RequestValidationError) -> JSONResponse:
errors = exc.errors()
for err in errors:
if "body" in err.get("loc", ()):
return JSONResponse(status_code=400, content={"detail": "body must not be empty"})
msg = str(errors[0]["msg"]) if errors else "invalid payload"
return JSONResponse(status_code=400, content={"detail": msg})
if __name__ == "__main__":
import uvicorn
host = os.environ.get("PHONE_CAPTURE_HOST", "0.0.0.0")
port = int(os.environ.get("PHONE_CAPTURE_PORT", "8765"))
uvicorn.run("app.main:app", host=host, port=port, reload=False)

45
server/app/models.py Normal file
View File

@@ -0,0 +1,45 @@
from pydantic import BaseModel, field_validator
from typing import Literal
class CaptureRequest(BaseModel):
id: str
created_at: str
kind: Literal["note", "todo"]
body: str
tags: list[str]
device: str
@field_validator("body")
@classmethod
def body_must_not_be_empty(cls, v: str) -> str:
stripped = v.strip()
if not stripped:
raise ValueError("body must not be empty")
return stripped
@field_validator("id")
@classmethod
def id_must_not_be_empty(cls, v: str) -> str:
if not v.strip():
raise ValueError("id must not be empty")
return v
@field_validator("device")
@classmethod
def device_must_not_be_empty(cls, v: str) -> str:
if not v.strip():
raise ValueError("device must not be empty")
return v
class CaptureResponse(BaseModel):
ok: bool
status: str
id: str
class HealthResponse(BaseModel):
ok: bool
service: str
version: str

68
server/app/org_writer.py Normal file
View File

@@ -0,0 +1,68 @@
import re
from datetime import datetime, timezone
from typing import Sequence
from .models import CaptureRequest
def normalize_tags(tags: Sequence[str]) -> list[str]:
seen: set[str] = set()
result: list[str] = []
for raw in tags:
tag = raw.strip().lower()
tag = tag.lstrip("#")
tag = tag.replace(" ", "_")
tag = re.sub(r"[^a-z0-9_]", "", tag)
if tag and tag not in seen:
seen.add(tag)
result.append(tag)
return result
def _org_tags(tags: list[str]) -> str:
if not tags:
return ""
return " :" + ":".join(tags) + ":"
def _created_stamp(created_at: str) -> str:
"""Parse ISO-8601 datetime and format as org inactive timestamp."""
try:
dt = datetime.fromisoformat(created_at)
except ValueError:
dt = datetime.now(timezone.utc)
day_abbr = dt.strftime("%a").lower()
return f"[{dt.strftime('%Y-%m-%d')} {day_abbr} {dt.strftime('%H:%M')}]"
def _property_drawer(created_stamp: str, source: str, capture_id: str) -> str:
return (
":PROPERTIES:\n"
f":CREATED: {created_stamp}\n"
f":SOURCE: {source}\n"
f":ID: {capture_id}\n"
":END:"
)
def format_capture(capture: CaptureRequest) -> str:
tags = normalize_tags(capture.tags)
tag_str = _org_tags(tags)
created_stamp = _created_stamp(capture.created_at)
drawer = _property_drawer(created_stamp, capture.device, capture.id)
if capture.kind == "todo":
heading = f"* TODO {capture.body}{tag_str}"
return f"{heading}\n{drawer}\n"
# note
lines = capture.body.split("\n")
first_line = lines[0].rstrip()
is_multiline = len(lines) > 1 and any(l.strip() for l in lines[1:])
if is_multiline:
heading = f"* note: {first_line}{tag_str}"
return f"{heading}\n{drawer}\n\n{capture.body}\n"
else:
heading = f"* note{tag_str}"
return f"{heading}\n{drawer}\n\n{capture.body}\n"

38
server/app/store.py Normal file
View File

@@ -0,0 +1,38 @@
import sqlite3
import threading
from pathlib import Path
_lock = threading.Lock()
def _connect(db_path: str) -> sqlite3.Connection:
conn = sqlite3.connect(db_path, check_same_thread=False)
conn.execute(
"CREATE TABLE IF NOT EXISTS seen_ids (id TEXT PRIMARY KEY, created_at TEXT NOT NULL)"
)
conn.commit()
return conn
class IdempotencyStore:
def __init__(self, db_path: str) -> None:
Path(db_path).parent.mkdir(parents=True, exist_ok=True)
self._conn = _connect(db_path)
def already_seen(self, capture_id: str) -> bool:
row = self._conn.execute(
"SELECT 1 FROM seen_ids WHERE id = ?", (capture_id,)
).fetchone()
return row is not None
def mark_seen(self, capture_id: str, created_at: str) -> None:
self._conn.execute(
"INSERT OR IGNORE INTO seen_ids (id, created_at) VALUES (?, ?)",
(capture_id, created_at),
)
self._conn.commit()
def get_file_lock() -> threading.Lock:
return _lock