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:
0
server/app/__init__.py
Normal file
0
server/app/__init__.py
Normal file
85
server/app/main.py
Normal file
85
server/app/main.py
Normal 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
45
server/app/models.py
Normal 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
68
server/app/org_writer.py
Normal 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
38
server/app/store.py
Normal 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
|
||||
Reference in New Issue
Block a user