diora-web/tui/diora_tui/annotations.py

259 lines
10 KiB
Python
Raw Normal View History

"""Highlights, notes and bookmarks of a book, as the web reader keeps them.
Format (static/js/app/72-highlights.js, 68-bookmarks.js): per book and kind one JSON list,
encrypted with the book key (AES-GCM, {ct, iv}), replaced as a whole on the server:
highlight {id, type: 'highlight'|'note', anchor, color, note, createdAt}
anchor {type: 'epub', chapterSrc, startBlockIndex, startChar, endBlockIndex, endChar, quote}
bookmark {id, label, anchor (the chapter's file), scrollFraction, createdAt} newest first
The server does no merging, so the apps write their whole list. The TUI keeps what it last
saw (`base`), and before it writes it merges its list with the server's current one by id
(three-way: what it added, deleted or changed since `base`, on top of what the server holds), so
a highlight another device made meanwhile is not wiped out. Its edits are queued (`dirty`) while
offline.
"""
from __future__ import annotations
import functools
import json
import os
import threading
import uuid
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from . import crypto, paths
COLORS = {"yellow": "#f1c40f", "green": "#2ecc71", "blue": "#3498db", "red": "#e63946"}
COLOR_NAMES = {"yellow": "Gelb", "green": "Grün", "blue": "Blau", "red": "Rot"}
KINDS = ("highlights", "bookmarks")
MAX_BYTES = {"highlights": 700 * 1024, "bookmarks": 100 * 1024} # the server's size guards (raw bytes)
QUOTE_MAX = 200
_DIR = paths.data_dir() / "annotations"
def _now_iso() -> str:
"""Like the browser's toISOString(): 2026-10-26T12:00:00.123Z."""
return datetime.now(timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z")
# ---- the items
def new_highlight(anchor: dict, color: str = "yellow", note: str = "") -> dict:
return {"id": str(uuid.uuid4()), "type": "highlight", "anchor": anchor, "color": color, "note": note, "createdAt": _now_iso()}
def new_note(anchor: dict, text: str) -> dict:
"""A freeform note: no mark in the text, its anchor only says where it belongs."""
return {"id": str(uuid.uuid4()), "type": "note", "anchor": anchor, "color": None, "note": text, "createdAt": _now_iso()}
def new_bookmark(label: str, anchor: str, scroll_fraction: float) -> dict:
return {"id": str(uuid.uuid4()), "label": label, "anchor": anchor, "scrollFraction": scroll_fraction, "createdAt": _now_iso()}
def make_anchor(chapter_src: str, start_block: int, start_char: int, end_block: int, end_char: int, quote: str) -> dict:
return {"type": "epub", "chapterSrc": chapter_src, "startBlockIndex": start_block, "startChar": start_char,
"endBlockIndex": end_block, "endChar": end_char, "quote": quote[:QUOTE_MAX]}
def sort_key(h: dict) -> tuple:
a = h.get("anchor") or {}
return (a.get("startBlockIndex", 10**9), a.get("startChar", 0))
# ---- merge
def merge(base: list[dict] | None, ours: list[dict], theirs: list[dict], *, prepend_added: bool = False) -> list[dict]:
"""The server's list (`theirs`) with our changes since `base` laid on it, by id: what we
deleted is gone, what we added is in, what we changed is ours (also against their change).
`base` None: we never saw the server's list, so nothing counts as deleted."""
base_by = {x["id"]: x for x in (base or []) if isinstance(x, dict) and "id" in x}
ours_by = {x["id"]: x for x in ours if isinstance(x, dict) and "id" in x}
deleted = set(base_by) - set(ours_by) if base is not None else set()
added = [x for x in ours if x["id"] not in base_by]
changed = {i for i, x in ours_by.items() if i in base_by and x != base_by[i]}
out, seen = [], set()
for x in theirs:
i = x.get("id")
if i in deleted:
continue
out.append(ours_by[i] if i in changed else x)
seen.add(i)
new = [x for x in added if x["id"] not in seen]
# a change of ours to something they deleted stays: losing an edit is worse than a revived item
revived = [ours_by[i] for i in changed if i not in seen and ours_by[i] not in new]
return (new + revived + out) if prepend_added else (out + revived + new)
# ---- the blobs
def decode(key_b64: str, blob: dict) -> list[dict]:
"""The server's {ct, iv} (ct may be null: none yet) as a list."""
if not blob or not blob.get("ct"):
return []
data = json.loads(crypto.decrypt(key_b64, blob["iv"], blob["ct"]).decode("utf-8"))
return [x for x in data if isinstance(x, dict) and x.get("id")] if isinstance(data, list) else []
def encode(key_b64: str, items: list[dict]) -> dict:
iv, ct = crypto.encrypt(key_b64, json.dumps(items, ensure_ascii=False, separators=(",", ":")).encode("utf-8"))
return {"ct": ct, "iv": iv}
# ---- local state
@dataclass
class BookAnnotations:
highlights: list[dict] = field(default_factory=list)
bookmarks: list[dict] = field(default_factory=list)
base: dict[str, list[dict] | None] = field(default_factory=lambda: {"highlights": None, "bookmarks": None})
dirty: dict[str, bool] = field(default_factory=lambda: {"highlights": False, "bookmarks": False})
def items(self, kind: str) -> list[dict]:
return self.highlights if kind == "highlights" else self.bookmarks
def _locked(fn):
@functools.wraps(fn)
def wrapper(self, *a, **kw):
with self._lock:
return fn(self, *a, **kw)
return wrapper
class AnnotationStore:
"""One JSON file per book key; shared by the reader (edits) and the sync workers."""
def __init__(self, directory: Path | None = None) -> None:
self.dir = directory or _DIR
self._lock = threading.RLock()
self._books: dict[str, BookAnnotations] = {}
def _file(self, key: str) -> Path:
return self.dir / f"{key}.json"
def _load(self, key: str) -> BookAnnotations:
if key in self._books:
return self._books[key]
ann = BookAnnotations()
try:
raw = json.loads(self._file(key).read_text(encoding="utf-8"))
ann.highlights = list(raw.get("highlights") or [])
ann.bookmarks = list(raw.get("bookmarks") or [])
ann.base = {k: raw.get("base", {}).get(k) for k in KINDS}
ann.dirty = {k: bool(raw.get("dirty", {}).get(k)) for k in KINDS}
except (OSError, ValueError, AttributeError):
pass
self._books[key] = ann
return ann
def _save(self, key: str) -> None:
ann = self._books[key]
self.dir.mkdir(parents=True, exist_ok=True)
tmp = self._file(key).with_suffix(".tmp")
tmp.write_text(json.dumps({"highlights": ann.highlights, "bookmarks": ann.bookmarks, "base": ann.base,
"dirty": ann.dirty}, ensure_ascii=False), encoding="utf-8")
os.replace(tmp, self._file(key))
@_locked
def get(self, key: str) -> BookAnnotations:
"""A copy (the lists are copied, not the items: don't change those)."""
a = self._load(key)
return BookAnnotations(list(a.highlights), list(a.bookmarks),
{k: (list(v) if v is not None else None) for k, v in a.base.items()}, dict(a.dirty))
@_locked
def dirty_keys(self) -> list[str]:
keys = set(self._books)
if self.dir.exists():
keys |= {p.stem for p in self.dir.glob("*.json")}
return sorted(k for k in keys if any(self._load(k).dirty.values()))
# ---- edits (they make the kind dirty: queued for the server)
@_locked
def add(self, key: str, kind: str, item: dict) -> None:
ann = self._load(key)
if kind == "bookmarks":
ann.bookmarks.insert(0, item) # newest first, like the web
else:
ann.highlights.append(item)
ann.dirty[kind] = True
self._save(key)
@_locked
def delete(self, key: str, kind: str, item_id: str) -> bool:
ann = self._load(key)
items = ann.items(kind)
kept = [x for x in items if x.get("id") != item_id]
if len(kept) == len(items):
return False
items[:] = kept
ann.dirty[kind] = True
self._save(key)
return True
@_locked
def set_note(self, key: str, item_id: str, text: str) -> bool:
ann = self._load(key)
for i, h in enumerate(ann.highlights):
if h.get("id") == item_id:
if h.get("note") == text:
return False
ann.highlights[i] = {**h, "note": text}
ann.dirty["highlights"] = True
self._save(key)
return True
return False
# ---- the server
@_locked
def take_remote(self, key: str, kind: str, theirs: list[dict]) -> None:
"""The server's current list: ours becomes it, with our queued edits laid on top."""
ann = self._load(key)
if ann.dirty[kind]:
merged = merge(ann.base[kind], ann.items(kind), theirs, prepend_added=kind == "bookmarks")
ann.items(kind)[:] = merged
else:
ann.items(kind)[:] = theirs
ann.base[kind] = list(theirs)
self._save(key)
@_locked
def for_push(self, key: str, kind: str, theirs: list[dict]) -> tuple[list[dict], list[dict]]:
"""(what to write over the server's current list `theirs`, our list as it is now)."""
ann = self._load(key)
ours = list(ann.items(kind))
return merge(ann.base[kind], ours, theirs, prepend_added=kind == "bookmarks"), ours
@_locked
def pushed(self, key: str, kind: str, written: list[dict], snapshot: list[dict]) -> None:
"""The server holds `written`, made from our list `snapshot`. Ours becomes `written`;
what was edited while it was on its way stays queued on top."""
ann = self._load(key)
current = ann.items(kind)
ann.base[kind] = list(written)
if current == snapshot:
current[:] = written
ann.dirty[kind] = False
else:
current[:] = merge(snapshot, current, written, prepend_added=kind == "bookmarks")
ann.dirty[kind] = True
self._save(key)
_default: AnnotationStore | None = None
def default() -> AnnotationStore:
global _default
if _default is None:
_default = AnnotationStore()
return _default