From 17757b4c14ba8fb8bc74f4d3e9bf5e6260fa7c63 Mon Sep 17 00:00:00 2001 From: Adam Patch Date: Fri, 29 Aug 2025 16:27:26 -0400 Subject: [PATCH 1/2] feat(api): finalize browse API implementation and tests; record completion (empty commit as working tree clean except submodule changes) From 1a99fcc3a98ccc273c97d9959fedf38add2fa3ba Mon Sep 17 00:00:00 2001 From: Adam Patch Date: Fri, 29 Aug 2025 16:41:14 -0400 Subject: [PATCH 2/2] feat(api): selections, selection_items, annotations SQLite tables + endpoints; tests for CRUD; closes task:core-architecture/mvp/annotations-and-selections --- scidk/app.py | 67 ++++++++++ scidk/core/annotations_sqlite.py | 148 +++++++++++++++++++++++ tests/test_annotations_and_selections.py | 41 +++++++ 3 files changed, 256 insertions(+) create mode 100644 scidk/core/annotations_sqlite.py create mode 100644 tests/test_annotations_and_selections.py diff --git a/scidk/app.py b/scidk/app.py index bfc1c1b..36cd35a 100644 --- a/scidk/app.py +++ b/scidk/app.py @@ -2,6 +2,8 @@ from pathlib import Path import os from typing import Optional +import time +import json from .core.graph import InMemoryGraph from .core.filesystem import FilesystemManager @@ -98,6 +100,9 @@ def create_app(): # API routes api = Blueprint('api', __name__, url_prefix='/api') + # Import SQLite layer for selections/annotations lazily to avoid circular deps + from .core import annotations_sqlite as ann_db + # Feature flag for rclone mount manager def _feature_rclone_mounts() -> bool: val = (os.environ.get('SCIDK_RCLONE_MOUNTS') or os.environ.get('SCIDK_FEATURE_RCLONE_MOUNTS') or '').strip().lower() @@ -2236,6 +2241,68 @@ def api_research_objects_get(ro_id): out.update({'file_count': files, 'folder_count': folders}) return jsonify(out), 200 + # Selections & Annotations API (SQLite-backed) + @api.post('/selections') + def api_create_selection(): + payload = request.get_json(silent=True) or {} + sel_id = (payload.get('id') or '').strip() + name = (payload.get('name') or '').strip() or None + import time as _t + ts = _t.time() + # If id not provided, derive short id from time + if not sel_id: + sel_id = hex(int(ts * 1000000))[2:] + item = ann_db.create_selection(sel_id, name, ts) + return jsonify(item), 201 + + @api.post('/selections//items') + def api_add_selection_items(sel_id): + payload = request.get_json(silent=True) or {} + file_ids = payload.get('file_ids') or payload.get('files') or [] + if not isinstance(file_ids, list): + return jsonify({'error': 'file_ids must be a list'}), 400 + import time as _t + ts = _t.time() + count = ann_db.add_selection_items(sel_id, [str(fid) for fid in file_ids], ts) + return jsonify({'selection_id': sel_id, 'added': int(count)}), 200 + + @api.post('/annotations') + def api_create_annotation(): + payload = request.get_json(silent=True) or {} + file_id = (payload.get('file_id') or '').strip() + if not file_id: + return jsonify({'error': 'file_id is required'}), 400 + kind = (payload.get('kind') or '').strip() or None + label = (payload.get('label') or '').strip() or None + note = payload.get('note') + if isinstance(note, str): + note = note + elif note is None: + note = None + else: + try: + note = json.dumps(note) + except Exception: + note = str(note) + data_json = payload.get('data_json') + if not isinstance(data_json, (str, type(None))): + try: + data_json = json.dumps(data_json) + except Exception: + data_json = None + import time as _t + ts = _t.time() + ann = ann_db.create_annotation(file_id, kind, label, note, data_json, ts) + return jsonify(ann), 201 + + @api.get('/annotations') + def api_get_annotations(): + file_id = (request.args.get('file_id') or '').strip() + if not file_id: + return jsonify({'error': 'file_id query parameter is required'}), 400 + items = ann_db.list_annotations_by_file(file_id) + return jsonify({'items': items, 'count': len(items)}), 200 + app.register_blueprint(api) # UI routes diff --git a/scidk/core/annotations_sqlite.py b/scidk/core/annotations_sqlite.py new file mode 100644 index 0000000..a39e81e --- /dev/null +++ b/scidk/core/annotations_sqlite.py @@ -0,0 +1,148 @@ +import os +import sqlite3 +from pathlib import Path +from typing import Optional, Dict, Any, List + +# SQLite storage for selections and annotations +# Tables: +# - selections(id TEXT PRIMARY KEY, name TEXT, created REAL) +# - selection_items(selection_id TEXT, file_id TEXT, created REAL, PRIMARY KEY(selection_id, file_id)) +# - annotations(id INTEGER PRIMARY KEY AUTOINCREMENT, file_id TEXT, kind TEXT, label TEXT, note TEXT, data_json TEXT, created REAL) +# Indexes for fast lookups + + +def _db_path() -> Path: + base = os.environ.get('SCIDK_DB_PATH') + if base: + p = Path(base) + else: + p = Path.home() / '.scidk' / 'db' / 'files.db' + p.parent.mkdir(parents=True, exist_ok=True) + return p + + +def connect() -> sqlite3.Connection: + p = _db_path() + conn = sqlite3.connect(str(p)) + try: + conn.execute('PRAGMA journal_mode=WAL;') + except Exception: + pass + return conn + + +def init_db(conn: Optional[sqlite3.Connection] = None): + own = False + if conn is None: + conn = connect() + own = True + try: + cur = conn.cursor() + cur.execute( + """ + CREATE TABLE IF NOT EXISTS selections ( + id TEXT PRIMARY KEY, + name TEXT, + created REAL + ); + """ + ) + cur.execute( + """ + CREATE TABLE IF NOT EXISTS selection_items ( + selection_id TEXT NOT NULL, + file_id TEXT NOT NULL, + created REAL, + PRIMARY KEY (selection_id, file_id) + ); + """ + ) + cur.execute( + """ + CREATE TABLE IF NOT EXISTS annotations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + file_id TEXT NOT NULL, + kind TEXT, + label TEXT, + note TEXT, + data_json TEXT, + created REAL + ); + """ + ) + # Indexes + cur.execute("CREATE INDEX IF NOT EXISTS idx_selection_items_sel ON selection_items(selection_id);") + cur.execute("CREATE INDEX IF NOT EXISTS idx_annotations_file ON annotations(file_id);") + cur.execute("CREATE INDEX IF NOT EXISTS idx_annotations_kind ON annotations(kind);") + conn.commit() + finally: + if own: + conn.close() + + +def create_selection(sel_id: str, name: Optional[str], created_ts: float) -> Dict[str, Any]: + conn = connect() + init_db(conn) + try: + conn.execute("INSERT OR IGNORE INTO selections(id, name, created) VALUES (?,?,?)", (sel_id, name, created_ts)) + conn.commit() + return {"id": sel_id, "name": name, "created": created_ts} + finally: + conn.close() + + +def add_selection_items(sel_id: str, file_ids: List[str], created_ts: float) -> int: + conn = connect() + init_db(conn) + try: + cur = conn.cursor() + cur.executemany( + "INSERT OR IGNORE INTO selection_items(selection_id, file_id, created) VALUES (?,?,?)", + [(sel_id, fid, created_ts) for fid in file_ids], + ) + conn.commit() + return cur.rowcount if cur.rowcount is not None else len(file_ids) + finally: + conn.close() + + +def create_annotation(file_id: str, kind: Optional[str], label: Optional[str], note: Optional[str], data_json: Optional[str], created_ts: float) -> Dict[str, Any]: + conn = connect() + init_db(conn) + try: + cur = conn.cursor() + cur.execute( + "INSERT INTO annotations(file_id, kind, label, note, data_json, created) VALUES (?,?,?,?,?,?)", + (file_id, kind, label, note, data_json, created_ts), + ) + conn.commit() + aid = cur.lastrowid + return {"id": aid, "file_id": file_id, "kind": kind, "label": label, "note": note, "data_json": data_json, "created": created_ts} + finally: + conn.close() + + +essential_fields = ["file_id", "kind", "label", "note", "data_json", "created"] + + +def list_annotations_by_file(file_id: str) -> List[Dict[str, Any]]: + conn = connect() + init_db(conn) + try: + cur = conn.cursor() + cur.execute("SELECT id, file_id, kind, label, note, data_json, created FROM annotations WHERE file_id = ? ORDER BY id DESC", (file_id,)) + rows = cur.fetchall() + res = [] + for r in rows: + res.append({ + "id": r[0], + "file_id": r[1], + "kind": r[2], + "label": r[3], + "note": r[4], + "data_json": r[5], + "created": r[6], + }) + return res + finally: + conn.close() diff --git a/tests/test_annotations_and_selections.py b/tests/test_annotations_and_selections.py new file mode 100644 index 0000000..7edac89 --- /dev/null +++ b/tests/test_annotations_and_selections.py @@ -0,0 +1,41 @@ +import json +import time +from scidk.app import create_app + + +def test_create_selection_and_items(client): + # Create selection + r = client.post('/api/selections', json={'name': 'My Selection'}) + assert r.status_code == 201 + data = r.get_json() + sel_id = data['id'] + assert sel_id + # Add items + r2 = client.post(f'/api/selections/{sel_id}/items', json={'file_ids': ['file1', 'file2']}) + assert r2.status_code == 200 + d2 = r2.get_json() + assert d2['selection_id'] == sel_id + assert d2['added'] >= 2 + + +def test_create_and_get_annotations(client): + now = time.time() + # Create annotation + payload = { + 'file_id': 'fileA', + 'kind': 'tag', + 'label': 'important', + 'note': 'check this later', + 'data_json': json.dumps({'a': 1}) + } + r = client.post('/api/annotations', json=payload) + assert r.status_code == 201 + created = r.get_json() + assert created['file_id'] == 'fileA' + assert created['kind'] == 'tag' + # Fetch by file_id + r2 = client.get('/api/annotations?file_id=fileA') + assert r2.status_code == 200 + body = r2.get_json() + assert body['count'] >= 1 + assert any(item['label'] == 'important' for item in body['items'])