Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 67 additions & 0 deletions scidk/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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/<sel_id>/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
Expand Down
148 changes: 148 additions & 0 deletions scidk/core/annotations_sqlite.py
Original file line number Diff line number Diff line change
@@ -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()
41 changes: 41 additions & 0 deletions tests/test_annotations_and_selections.py
Original file line number Diff line number Diff line change
@@ -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'])