codebook / potato /memos /store.py
davidjurgens's picture
Deploy: Potato — Codebook Annotation
aceb1b2 verified
Raw
History Blame Contribute Delete
4.46 kB
"""
Memo storage (universal).
SQLite-backed CRUD over the `memos` table in `<task_dir>/project.sqlite`
via the universal persistence layer. No visibility/permission logic lives
here — that is the service layer's job. This module only persists rows.
A memo is a free-text note an annotator attaches to an instance, or to a
text selection within an instance (offset-anchored). Universal: usable in
standard annotation, solo mode, and QDA mode.
"""
from __future__ import annotations
import json
import time
import uuid
from typing import Any, Dict, List, Optional
from potato.persistence import Migration, get_db, register_migration
_MEMOS_MIGRATION = Migration(
name="0001_memos",
sql="""
CREATE TABLE IF NOT EXISTS memos (
id TEXT PRIMARY KEY,
project TEXT NOT NULL,
instance_id TEXT NOT NULL,
anchor TEXT, -- NULL = instance-level; else JSON {start,end,field}
body TEXT NOT NULL,
created_by TEXT NOT NULL,
created_at REAL NOT NULL,
updated_at REAL NOT NULL,
visibility TEXT NOT NULL DEFAULT 'private'
CHECK (visibility IN ('private', 'shared'))
);
CREATE INDEX IF NOT EXISTS idx_memos_instance
ON memos (project, instance_id);
CREATE INDEX IF NOT EXISTS idx_memos_author
ON memos (project, created_by);
""",
)
# Registered at import so the table exists on the first get_db() call.
register_migration(_MEMOS_MIGRATION)
def _db(task_dir: str):
"""Connection for the memos store, guaranteeing the migration is
registered first. register_migration is idempotent, so this is a
no-op in normal operation; it makes the store robust if a test
helper (clear_migrations) wiped the process-global registry before
the first get_db() for this task_dir opens the connection."""
register_migration(_MEMOS_MIGRATION)
return get_db(task_dir)
def _row_to_dict(row) -> Dict[str, Any]:
d = dict(row)
d["anchor"] = json.loads(d["anchor"]) if d["anchor"] else None
return d
def create(
task_dir: str,
*,
project: str,
instance_id: str,
body: str,
created_by: str,
anchor: Optional[Dict[str, Any]] = None,
visibility: str = "private",
) -> Dict[str, Any]:
"""Insert a memo row and return it as a dict."""
memo_id = uuid.uuid4().hex
now = time.time()
conn = _db(task_dir)
conn.execute(
"""INSERT INTO memos
(id, project, instance_id, anchor, body, created_by,
created_at, updated_at, visibility)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(
memo_id, project, instance_id,
json.dumps(anchor) if anchor else None,
body, created_by, now, now, visibility,
),
)
conn.commit()
return get(task_dir, memo_id)
def get(task_dir: str, memo_id: str) -> Optional[Dict[str, Any]]:
row = _db(task_dir).execute(
"SELECT * FROM memos WHERE id = ?", (memo_id,)
).fetchone()
return _row_to_dict(row) if row else None
def list_for_instance(
task_dir: str, project: str, instance_id: str
) -> List[Dict[str, Any]]:
"""All memos on an instance (no visibility filtering — service does that)."""
rows = _db(task_dir).execute(
"""SELECT * FROM memos
WHERE project = ? AND instance_id = ?
ORDER BY created_at ASC""",
(project, instance_id),
).fetchall()
return [_row_to_dict(r) for r in rows]
def update(
task_dir: str,
memo_id: str,
*,
body: Optional[str] = None,
visibility: Optional[str] = None,
) -> Optional[Dict[str, Any]]:
"""Patch body and/or visibility; bumps updated_at. No-op fields ignored."""
sets, params = [], []
if body is not None:
sets.append("body = ?")
params.append(body)
if visibility is not None:
sets.append("visibility = ?")
params.append(visibility)
if not sets:
return get(task_dir, memo_id)
sets.append("updated_at = ?")
params.append(time.time())
params.append(memo_id)
conn = _db(task_dir)
conn.execute(f"UPDATE memos SET {', '.join(sets)} WHERE id = ?", params)
conn.commit()
return get(task_dir, memo_id)
def delete(task_dir: str, memo_id: str) -> bool:
conn = _db(task_dir)
cur = conn.execute("DELETE FROM memos WHERE id = ?", (memo_id,))
conn.commit()
return cur.rowcount > 0