Initial commit

Photo-based book cataloger with AI identification.
Room → Cabinet → Shelf → Book hierarchy; FastAPI + SQLite backend;
vanilla JS SPA; OpenAI-compatible plugin system for boundary
detection, text recognition, and archive search.
This commit is contained in:
2026-03-09 14:17:13 +03:00
commit 084d1aebd5
64 changed files with 8605 additions and 0 deletions

515
src/db.py Normal file
View File

@@ -0,0 +1,515 @@
"""
Database layer: schema, connection/transaction lifecycle, and all query functions.
No file I/O, no config, no business logic. All SQL lives here.
"""
import json
import sqlite3
import uuid
from collections.abc import Iterator
from contextlib import contextmanager
from datetime import datetime
from pathlib import Path
from mashumaro.codecs import BasicDecoder
from models import BookRow, CabinetRow, RoomRow, ShelfRow
DB_PATH = Path("data") / "books.db"
# ── Schema ─────────────────────────────────────────────────────────────────────
SCHEMA = """
CREATE TABLE IF NOT EXISTS rooms (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
position INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS cabinets (
id TEXT PRIMARY KEY,
room_id TEXT NOT NULL REFERENCES rooms(id) ON DELETE CASCADE,
name TEXT NOT NULL,
photo_filename TEXT,
shelf_boundaries TEXT DEFAULT NULL,
ai_shelf_boundaries TEXT DEFAULT NULL,
position INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS shelves (
id TEXT PRIMARY KEY,
cabinet_id TEXT NOT NULL REFERENCES cabinets(id) ON DELETE CASCADE,
name TEXT NOT NULL,
photo_filename TEXT,
book_boundaries TEXT DEFAULT NULL,
ai_book_boundaries TEXT DEFAULT NULL,
position INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS books (
id TEXT PRIMARY KEY,
shelf_id TEXT NOT NULL REFERENCES shelves(id) ON DELETE CASCADE,
position INTEGER NOT NULL DEFAULT 0,
image_filename TEXT,
title TEXT DEFAULT '',
author TEXT DEFAULT '',
year TEXT DEFAULT '',
isbn TEXT DEFAULT '',
publisher TEXT DEFAULT '',
notes TEXT DEFAULT '',
raw_text TEXT DEFAULT '',
ai_title TEXT DEFAULT '',
ai_author TEXT DEFAULT '',
ai_year TEXT DEFAULT '',
ai_isbn TEXT DEFAULT '',
ai_publisher TEXT DEFAULT '',
identification_status TEXT DEFAULT 'unidentified',
title_confidence REAL DEFAULT 0,
analyzed_at TEXT,
created_at TEXT NOT NULL,
candidates TEXT DEFAULT NULL
);
"""
# ── Mashumaro decoders for entity rows ────────────────────────────────────────
_room_dec: BasicDecoder[RoomRow] = BasicDecoder(RoomRow)
_cabinet_dec: BasicDecoder[CabinetRow] = BasicDecoder(CabinetRow)
_shelf_dec: BasicDecoder[ShelfRow] = BasicDecoder(ShelfRow)
_book_dec: BasicDecoder[BookRow] = BasicDecoder(BookRow)
def _room(row: sqlite3.Row) -> RoomRow:
return _room_dec.decode(dict(row))
def _cabinet(row: sqlite3.Row) -> CabinetRow:
return _cabinet_dec.decode(dict(row))
def _shelf(row: sqlite3.Row) -> ShelfRow:
return _shelf_dec.decode(dict(row))
def _book(row: sqlite3.Row) -> BookRow:
return _book_dec.decode(dict(row))
# ── DB init + connection ────────────────────────────────────────────────────────
def init_db() -> None:
DB_PATH.parent.mkdir(exist_ok=True)
c = conn()
c.executescript(SCHEMA)
c.commit()
c.close()
def conn() -> sqlite3.Connection:
c = sqlite3.connect(DB_PATH)
c.row_factory = sqlite3.Row
c.execute("PRAGMA foreign_keys = ON")
return c
# ── Context managers ──────────────────────────────────────────────────────────
@contextmanager
def connection() -> Iterator[sqlite3.Connection]:
"""Read-only context: opens a connection, closes on exit."""
c = conn()
try:
yield c
finally:
c.close()
@contextmanager
def transaction() -> Iterator[sqlite3.Connection]:
"""Write context: opens, commits on success, rolls back on exception."""
c = conn()
try:
yield c
c.commit()
except Exception:
c.rollback()
raise
finally:
c.close()
# ── Helpers ───────────────────────────────────────────────────────────────────
COUNTERS: dict[str, int] = {}
def uid() -> str:
return str(uuid.uuid4())
def now() -> str:
return datetime.now().isoformat()
def next_pos(db: sqlite3.Connection, table: str, parent_col: str, parent_id: str) -> int:
row = db.execute(f"SELECT COALESCE(MAX(position),0)+1 FROM {table} WHERE {parent_col}=?", [parent_id]).fetchone()
return int(row[0])
def next_root_pos(db: sqlite3.Connection, table: str) -> int:
row = db.execute(f"SELECT COALESCE(MAX(position),0)+1 FROM {table}").fetchone()
return int(row[0])
def next_name(prefix: str) -> str:
COUNTERS[prefix] = COUNTERS.get(prefix, 0) + 1
return f"{prefix} {COUNTERS[prefix]}"
# ── Tree ──────────────────────────────────────────────────────────────────────
def get_tree(db: sqlite3.Connection) -> list[dict[str, object]]:
"""Build and return the full nested Room→Cabinet→Shelf→Book tree."""
rooms: list[dict[str, object]] = [dict(r) for r in db.execute("SELECT * FROM rooms ORDER BY position")]
for room in rooms:
cabs: list[dict[str, object]] = [
dict(c) for c in db.execute("SELECT * FROM cabinets WHERE room_id=? ORDER BY position", [room["id"]])
]
for cab in cabs:
shelves: list[dict[str, object]] = [
dict(s) for s in db.execute("SELECT * FROM shelves WHERE cabinet_id=? ORDER BY position", [cab["id"]])
]
for shelf in shelves:
shelf["books"] = [
dict(b) for b in db.execute("SELECT * FROM books WHERE shelf_id=? ORDER BY position", [shelf["id"]])
]
cab["shelves"] = shelves
room["cabinets"] = cabs
return rooms
# ── Rooms ─────────────────────────────────────────────────────────────────────
def get_room(db: sqlite3.Connection, room_id: str) -> RoomRow | None:
row = db.execute("SELECT * FROM rooms WHERE id=?", [room_id]).fetchone()
return _room(row) if row else None
def create_room(db: sqlite3.Connection) -> RoomRow:
data = {"id": uid(), "name": next_name("Room"), "position": next_root_pos(db, "rooms"), "created_at": now()}
db.execute("INSERT INTO rooms VALUES(:id,:name,:position,:created_at)", data)
return _room_dec.decode(data)
def rename_room(db: sqlite3.Connection, room_id: str, name: str) -> None:
db.execute("UPDATE rooms SET name=? WHERE id=?", [name, room_id])
def collect_room_photos(db: sqlite3.Connection, room_id: str) -> list[str]:
"""Return all photo filenames for cabinets/shelves/books under this room."""
photos: list[str] = []
for r in db.execute(
"SELECT image_filename FROM books WHERE shelf_id IN "
"(SELECT id FROM shelves WHERE cabinet_id IN (SELECT id FROM cabinets WHERE room_id=?))",
[room_id],
):
if r[0]:
photos.append(str(r[0]))
for r in db.execute(
"SELECT photo_filename FROM shelves WHERE cabinet_id IN (SELECT id FROM cabinets WHERE room_id=?)", [room_id]
):
if r[0]:
photos.append(str(r[0]))
for r in db.execute("SELECT photo_filename FROM cabinets WHERE room_id=?", [room_id]):
if r[0]:
photos.append(str(r[0]))
return photos
def delete_room(db: sqlite3.Connection, room_id: str) -> None:
"""Delete room; SQLite ON DELETE CASCADE removes all children."""
db.execute("DELETE FROM rooms WHERE id=?", [room_id])
# ── Cabinets ──────────────────────────────────────────────────────────────────
def get_cabinet(db: sqlite3.Connection, cabinet_id: str) -> CabinetRow | None:
row = db.execute("SELECT * FROM cabinets WHERE id=?", [cabinet_id]).fetchone()
return _cabinet(row) if row else None
def create_cabinet(db: sqlite3.Connection, room_id: str) -> CabinetRow:
data: dict[str, object] = {
"id": uid(),
"room_id": room_id,
"name": next_name("Cabinet"),
"photo_filename": None,
"shelf_boundaries": None,
"ai_shelf_boundaries": None,
"position": next_pos(db, "cabinets", "room_id", room_id),
"created_at": now(),
}
db.execute(
"INSERT INTO cabinets VALUES("
":id,:room_id,:name,:photo_filename,:shelf_boundaries,"
":ai_shelf_boundaries,:position,:created_at)",
data,
)
return _cabinet_dec.decode(data)
def rename_cabinet(db: sqlite3.Connection, cabinet_id: str, name: str) -> None:
db.execute("UPDATE cabinets SET name=? WHERE id=?", [name, cabinet_id])
def collect_cabinet_photos(db: sqlite3.Connection, cabinet_id: str) -> list[str]:
photos: list[str] = []
for r in db.execute(
"SELECT image_filename FROM books WHERE shelf_id IN (SELECT id FROM shelves WHERE cabinet_id=?)", [cabinet_id]
):
if r[0]:
photos.append(str(r[0]))
for r in db.execute("SELECT photo_filename FROM shelves WHERE cabinet_id=?", [cabinet_id]):
if r[0]:
photos.append(str(r[0]))
row = db.execute("SELECT photo_filename FROM cabinets WHERE id=?", [cabinet_id]).fetchone()
if row and row[0]:
photos.append(str(row[0]))
return photos
def delete_cabinet(db: sqlite3.Connection, cabinet_id: str) -> None:
db.execute("DELETE FROM cabinets WHERE id=?", [cabinet_id])
def get_cabinet_photo(db: sqlite3.Connection, cabinet_id: str) -> str | None:
row = db.execute("SELECT photo_filename FROM cabinets WHERE id=?", [cabinet_id]).fetchone()
return str(row[0]) if row and row[0] else None
def set_cabinet_photo(db: sqlite3.Connection, cabinet_id: str, filename: str) -> None:
db.execute("UPDATE cabinets SET photo_filename=? WHERE id=?", [filename, cabinet_id])
def set_cabinet_boundaries(db: sqlite3.Connection, cabinet_id: str, boundaries_json: str) -> None:
db.execute("UPDATE cabinets SET shelf_boundaries=? WHERE id=?", [boundaries_json, cabinet_id])
def set_ai_shelf_boundaries(db: sqlite3.Connection, cabinet_id: str, plugin_id: str, boundaries: list[float]) -> None:
row = db.execute("SELECT ai_shelf_boundaries FROM cabinets WHERE id=?", [cabinet_id]).fetchone()
current: dict[str, object] = json.loads(row[0]) if row and row[0] else {}
current[plugin_id] = boundaries
db.execute("UPDATE cabinets SET ai_shelf_boundaries=? WHERE id=?", [json.dumps(current), cabinet_id])
# ── Shelves ───────────────────────────────────────────────────────────────────
def get_shelf(db: sqlite3.Connection, shelf_id: str) -> ShelfRow | None:
row = db.execute("SELECT * FROM shelves WHERE id=?", [shelf_id]).fetchone()
return _shelf(row) if row else None
def create_shelf(db: sqlite3.Connection, cabinet_id: str) -> ShelfRow:
data: dict[str, object] = {
"id": uid(),
"cabinet_id": cabinet_id,
"name": next_name("Shelf"),
"photo_filename": None,
"book_boundaries": None,
"ai_book_boundaries": None,
"position": next_pos(db, "shelves", "cabinet_id", cabinet_id),
"created_at": now(),
}
db.execute(
"INSERT INTO shelves VALUES("
":id,:cabinet_id,:name,:photo_filename,:book_boundaries,:ai_book_boundaries,:position,:created_at)",
data,
)
return _shelf_dec.decode(data)
def rename_shelf(db: sqlite3.Connection, shelf_id: str, name: str) -> None:
db.execute("UPDATE shelves SET name=? WHERE id=?", [name, shelf_id])
def collect_shelf_photos(db: sqlite3.Connection, shelf_id: str) -> list[str]:
photos: list[str] = []
row = db.execute("SELECT photo_filename FROM shelves WHERE id=?", [shelf_id]).fetchone()
if row and row[0]:
photos.append(str(row[0]))
for r in db.execute("SELECT image_filename FROM books WHERE shelf_id=?", [shelf_id]):
if r[0]:
photos.append(str(r[0]))
return photos
def delete_shelf(db: sqlite3.Connection, shelf_id: str) -> None:
db.execute("DELETE FROM shelves WHERE id=?", [shelf_id])
def get_shelf_photo(db: sqlite3.Connection, shelf_id: str) -> str | None:
row = db.execute("SELECT photo_filename FROM shelves WHERE id=?", [shelf_id]).fetchone()
return str(row[0]) if row and row[0] else None
def set_shelf_photo(db: sqlite3.Connection, shelf_id: str, filename: str) -> None:
db.execute("UPDATE shelves SET photo_filename=? WHERE id=?", [filename, shelf_id])
def set_shelf_boundaries(db: sqlite3.Connection, shelf_id: str, boundaries_json: str) -> None:
db.execute("UPDATE shelves SET book_boundaries=? WHERE id=?", [boundaries_json, shelf_id])
def set_ai_book_boundaries(db: sqlite3.Connection, shelf_id: str, plugin_id: str, boundaries: list[float]) -> None:
row = db.execute("SELECT ai_book_boundaries FROM shelves WHERE id=?", [shelf_id]).fetchone()
current: dict[str, object] = json.loads(row[0]) if row and row[0] else {}
current[plugin_id] = boundaries
db.execute("UPDATE shelves SET ai_book_boundaries=? WHERE id=?", [json.dumps(current), shelf_id])
def get_shelf_rank(db: sqlite3.Connection, shelf_id: str) -> int:
"""0-based rank of shelf among its siblings sorted by position."""
row = db.execute("SELECT cabinet_id FROM shelves WHERE id=?", [shelf_id]).fetchone()
if not row:
return 0
siblings = [r[0] for r in db.execute("SELECT id FROM shelves WHERE cabinet_id=? ORDER BY position", [row[0]])]
return siblings.index(shelf_id) if shelf_id in siblings else 0
# ── Books ─────────────────────────────────────────────────────────────────────
def get_book(db: sqlite3.Connection, book_id: str) -> BookRow | None:
row = db.execute("SELECT * FROM books WHERE id=?", [book_id]).fetchone()
return _book(row) if row else None
def create_book(db: sqlite3.Connection, shelf_id: str) -> BookRow:
data: dict[str, object] = {
"id": uid(),
"shelf_id": shelf_id,
"position": next_pos(db, "books", "shelf_id", shelf_id),
"image_filename": None,
"title": "",
"author": "",
"year": "",
"isbn": "",
"publisher": "",
"notes": "",
"raw_text": "",
"ai_title": "",
"ai_author": "",
"ai_year": "",
"ai_isbn": "",
"ai_publisher": "",
"identification_status": "unidentified",
"title_confidence": 0,
"analyzed_at": None,
"created_at": now(),
"candidates": None,
}
db.execute(
"INSERT INTO books VALUES(:id,:shelf_id,:position,:image_filename,:title,:author,:year,:isbn,:publisher,"
":notes,:raw_text,:ai_title,:ai_author,:ai_year,:ai_isbn,:ai_publisher,:identification_status,"
":title_confidence,:analyzed_at,:created_at,:candidates)",
data,
)
return _book_dec.decode(data)
def delete_book(db: sqlite3.Connection, book_id: str) -> None:
db.execute("DELETE FROM books WHERE id=?", [book_id])
def get_book_photo(db: sqlite3.Connection, book_id: str) -> str | None:
row = db.execute("SELECT image_filename FROM books WHERE id=?", [book_id]).fetchone()
return str(row[0]) if row and row[0] else None
def set_book_photo(db: sqlite3.Connection, book_id: str, filename: str) -> None:
db.execute("UPDATE books SET image_filename=? WHERE id=?", [filename, book_id])
def set_user_book_fields(
db: sqlite3.Connection,
book_id: str,
title: str,
author: str,
year: str,
isbn: str,
publisher: str,
notes: str,
) -> None:
"""Set both user fields and ai_* fields (user edit is the authoritative identification)."""
db.execute(
"UPDATE books SET title=?,author=?,year=?,isbn=?,publisher=?,notes=?,"
"ai_title=?,ai_author=?,ai_year=?,ai_isbn=?,ai_publisher=? WHERE id=?",
[title, author, year, isbn, publisher, notes, title, author, year, isbn, publisher, book_id],
)
def set_book_status(db: sqlite3.Connection, book_id: str, status: str) -> None:
db.execute("UPDATE books SET identification_status=? WHERE id=?", [status, book_id])
def set_book_confidence(db: sqlite3.Connection, book_id: str, confidence: float, analyzed_at: str) -> None:
db.execute(
"UPDATE books SET title_confidence=?, analyzed_at=? WHERE id=?",
[confidence, analyzed_at, book_id],
)
def set_book_ai_fields(
db: sqlite3.Connection,
book_id: str,
ai_title: str,
ai_author: str,
ai_year: str,
ai_isbn: str,
ai_publisher: str,
) -> None:
db.execute(
"UPDATE books SET ai_title=?,ai_author=?,ai_year=?,ai_isbn=?,ai_publisher=? WHERE id=?",
[ai_title, ai_author, ai_year, ai_isbn, ai_publisher, book_id],
)
def set_book_ai_field(db: sqlite3.Connection, book_id: str, field: str, value: str) -> None:
"""Set a single ai_* field by name (used in dismiss_field logic)."""
# field is validated by caller to be in AI_FIELDS
db.execute(f"UPDATE books SET ai_{field}=? WHERE id=?", [value, book_id])
def set_book_raw_text(db: sqlite3.Connection, book_id: str, raw_text: str) -> None:
db.execute("UPDATE books SET raw_text=? WHERE id=?", [raw_text, book_id])
def set_book_candidates(db: sqlite3.Connection, book_id: str, candidates_json: str) -> None:
db.execute("UPDATE books SET candidates=? WHERE id=?", [candidates_json, book_id])
def get_book_rank(db: sqlite3.Connection, book_id: str) -> int:
"""0-based rank of book among its siblings sorted by position."""
row = db.execute("SELECT shelf_id FROM books WHERE id=?", [book_id]).fetchone()
if not row:
return 0
siblings = [r[0] for r in db.execute("SELECT id FROM books WHERE shelf_id=? ORDER BY position", [row[0]])]
return siblings.index(book_id) if book_id in siblings else 0
def get_unidentified_book_ids(db: sqlite3.Connection) -> list[str]:
return [str(r[0]) for r in db.execute("SELECT id FROM books WHERE identification_status='unidentified'")]
# ── Reorder ───────────────────────────────────────────────────────────────────
def reorder_entities(db: sqlite3.Connection, table: str, ids: list[str]) -> None:
for i, entity_id in enumerate(ids, 1):
db.execute(f"UPDATE {table} SET position=? WHERE id=?", [i, entity_id])