"""Persist log entries as append-only JSONL with locked full rewrites. Creation appends one line; updates and deletes rewrite the file atomically. """ from datetime import date, datetime, timezone from uuid import uuid4 from app.fsutil import append_jsonl, read_jsonl, rewrite_jsonl from app.models import CoachMeta, Entry, EntryCreate, EntryUpdate, Result from app.paths import Paths def _utc_now() -> datetime: return datetime.now(timezone.utc) class EntryStore: """Read and mutate the durable entries JSONL file.""" def __init__(self, paths: Paths) -> None: self.paths = paths def create(self, data: EntryCreate) -> Entry: """Append a new entry and return the stored record.""" now = _utc_now() entry = Entry( id=str(uuid4()), ts=data.ts or now, created_at=now, updated_at=now, activity=data.activity, happened=data.happened, emotions=data.emotions, intensity=data.intensity, remedy=data.remedy, result=data.result, tags=data.tags, notes=data.notes, fse_spike=data.fse_spike, avoidance_types=data.avoidance_types, proof_brick=data.proof_brick, walk_type=data.walk_type, coach=CoachMeta(), ) append_jsonl(self.paths.entries, entry.model_dump(mode="json")) return entry def _load(self) -> list[Entry]: return [Entry.model_validate(record) for record in read_jsonl(self.paths.entries)] def get(self, entry_id: str) -> Entry | None: """Return a single entry by id, or None when absent.""" for entry in self._load(): if entry.id == entry_id: return entry return None def list( self, *, start: date | None = None, end: date | None = None, tag: str | None = None, result: Result | None = None, emotion: str | None = None, q: str | None = None, limit: int = 50, offset: int = 0, ) -> tuple[list[Entry], int]: """Return newest-first entries matching filters plus the total count.""" needle = q.lower() if q else None matches: list[Entry] = [] for entry in self._load(): if start and entry.ts.date() < start: continue if end and entry.ts.date() > end: continue if tag and tag not in entry.tags: continue if result and entry.result != result: continue if emotion and emotion not in entry.emotions: continue if needle: haystack = " ".join( [entry.activity, entry.happened, entry.remedy, entry.notes] ).lower() if needle not in haystack: continue matches.append(entry) matches.sort(key=lambda item: item.ts, reverse=True) total = len(matches) page = matches[offset : offset + limit] return page, total def update(self, entry_id: str, patch: EntryUpdate) -> Entry | None: """Apply a partial update and rewrite the file, or return None.""" changes = patch.model_dump(exclude_unset=True, mode="json") records = read_jsonl(self.paths.entries) updated: Entry | None = None out: list[dict] = [] for record in records: if updated is None and record.get("id") == entry_id: merged = {**record, **changes, "updated_at": _utc_now().isoformat()} updated = Entry.model_validate(merged) out.append(updated.model_dump(mode="json")) else: out.append(record) if updated is None: return None rewrite_jsonl(self.paths.entries, out) return updated def delete(self, entry_id: str) -> bool: """Hard-delete an entry, returning whether it existed.""" records = read_jsonl(self.paths.entries) out = [record for record in records if record.get("id") != entry_id] if len(out) == len(records): return False rewrite_jsonl(self.paths.entries, out) return True def set_coach(self, entry_id: str, coach: CoachMeta) -> Entry | None: """Persist coach metadata on an entry without changing other fields.""" records = read_jsonl(self.paths.entries) updated: Entry | None = None out: list[dict] = [] for record in records: if updated is None and record.get("id") == entry_id: merged = { **record, "coach": coach.model_dump(mode="json"), "updated_at": _utc_now().isoformat(), } updated = Entry.model_validate(merged) out.append(updated.model_dump(mode="json")) else: out.append(record) if updated is None: return None rewrite_jsonl(self.paths.entries, out) return updated