Replace the old "patches" format (path:lang composite keys, ordered hunk lists, text-anchored matching) with Data.overrides: path -> lang -> LangEdits, keyed throughout so a save's database diff touches only the edited chunks. The old "patches" key is ignored on decode, discarding legacy data without a migration. - Whole-paragraph additions/deletions are structural: a drop flag on the original chunk hash, and additions in their own dict anchored from the neighboring chunks' before/after (first live referrer wins), so they stay in place across retranslation and one-sided original edits. - Within-paragraph edits (up to a full paragraph rewrite or split) are full-chunk replace patches applied by chunk hash alone: a retranslation is overridden wholesale, so user edits survive AI re-runs; editing the original changes the hash and orphans the patch. The old search-matching staleness gate is gone. - Saving a translation on a page without original chunks is rejected (REST 400 / WS error); emptying the original afterwards renders the translation empty, with the orphaned overrides inert.
173 lines
6.5 KiB
Python
173 lines
6.5 KiB
Python
"""Kanta schema migrations, discovered by name (``migrate_vN``).
|
|
|
|
Each function receives the raw state dict (JSON-level: bytes are base64
|
|
strings, datetimes RFC 3339 strings, struct fields with default values
|
|
omitted) before it is decoded into ``Data`` structs, and runs exactly once
|
|
per database based on its recorded version.
|
|
|
|
All storage/schema upgrades live here — including on-disk file work, which
|
|
runs through files.py's file store (imported lazily: files.py owns the store
|
|
and state.py passes this module to Kanta; at migration time, during lifespan
|
|
``kanta.open()``, both modules are fully loaded).
|
|
"""
|
|
|
|
import base64
|
|
import re
|
|
from pathlib import Path
|
|
|
|
from pagerite.chunks import chunk_key, chunk_markdown
|
|
from pagerite.data import prettify
|
|
|
|
|
|
def _append_order(nodes: dict) -> float:
|
|
"""Raw-dict equivalent of data.append_order (order keys may be absent)."""
|
|
return max((n.get("order", 0) for n in nodes.values()), default=0) + 1
|
|
|
|
|
|
def _ensure(menu: dict, path: str) -> dict:
|
|
"""Raw-dict equivalent of state._ensure: the node dict at ``path``,
|
|
creating it and any missing ancestors (content-less category labels)
|
|
appended at the end of their level."""
|
|
nodes = menu
|
|
node = None
|
|
for seg in path.split("/"):
|
|
node = nodes.get(seg)
|
|
if node is None:
|
|
node = {"title": prettify(seg), "order": _append_order(nodes)}
|
|
nodes[seg] = node
|
|
nodes = node.setdefault("children", {})
|
|
return node
|
|
|
|
|
|
def migrate_v1(d: dict) -> None:
|
|
"""Move in-database file blobs to the on-disk content-addressed store,
|
|
and rebuild the legacy flat page store (``pages``) as the menu tree."""
|
|
files = d.pop("files", None)
|
|
if files:
|
|
from pagerite.files import file_store
|
|
|
|
for name, body in files.items():
|
|
if isinstance(body, str): # JSON-level bytes are base64 strings
|
|
body = base64.b64decode(body)
|
|
file_store.put(name, body)
|
|
pages = d.pop("pages", None)
|
|
if not pages:
|
|
return
|
|
menu = d.setdefault("menu", {})
|
|
for path, page in pages.items():
|
|
node = _ensure(menu, path)
|
|
node["title"] = page["title"]
|
|
node["content"] = page["markdown"]
|
|
for key in ("banner", "published", "order", "created", "modified"):
|
|
if key in page:
|
|
node[key] = page[key]
|
|
|
|
|
|
#: Extension-less file links: uploaded images are linked as /_f/<hash>
|
|
#: and the server negotiates avif/webp/jpg from the Accept header.
|
|
_DERIVATIVE_LINK = re.compile(r"(/_f/[0-9a-f]{12})\.(?:avif|webp)\b")
|
|
|
|
|
|
def _backfill_derivatives() -> None:
|
|
"""Create missing AVIF/WebP/JPEG derivatives for files stored before
|
|
they were introduced (older uploads may have only the original plus
|
|
AVIF, and SVGs no raster variants at all). WebP/JPEG are re-encoded
|
|
from an existing AVIF when available, everything else from the
|
|
original (SVGs rasterized first)."""
|
|
from pagerite.files import (
|
|
IMAGE_JPG_QUALITY,
|
|
IMAGE_MAXSIZE,
|
|
IMAGE_WEBP_QUALITY,
|
|
_avif_to_format,
|
|
_svg_to_png,
|
|
_to_avif,
|
|
file_store,
|
|
)
|
|
|
|
try:
|
|
paths = [f for f in file_store.path.iterdir() if f.is_file()]
|
|
except FileNotFoundError:
|
|
return
|
|
groups: dict[str, list[Path]] = {}
|
|
for p in paths:
|
|
groups.setdefault(p.name.partition(".")[0], []).append(p)
|
|
for digest, files in groups.items():
|
|
names = {p.name for p in files}
|
|
source = next(
|
|
(p for p in files if ".orig." in p.name or p.suffix == ".svg"), None
|
|
)
|
|
if source is None:
|
|
continue # plain as-is file, no derivatives to make
|
|
avif = file_store.get(f"{digest}.avif")
|
|
if avif is None:
|
|
ext = source.suffix
|
|
body = source.read_bytes()
|
|
if ext == ".svg":
|
|
png = _svg_to_png(body, IMAGE_MAXSIZE)
|
|
if png is None:
|
|
continue
|
|
body, ext = png, ".png"
|
|
converted = _to_avif(body, ext)
|
|
if converted is None:
|
|
continue
|
|
file_store.put(f"{digest}.avif", converted)
|
|
avif = file_store.get(f"{digest}.avif")
|
|
for fmt, quality in (
|
|
("webp", IMAGE_WEBP_QUALITY),
|
|
("jpg", IMAGE_JPG_QUALITY),
|
|
):
|
|
if f"{digest}.{fmt}" not in names:
|
|
file_store.put(
|
|
f"{digest}.{fmt}", _avif_to_format(avif[0], f".{fmt}", quality)
|
|
)
|
|
|
|
|
|
def migrate_v2(d: dict) -> None:
|
|
"""Extension-less image links: strip .avif/.webp extensions from /_f/
|
|
links in page content and banners (the server now negotiates the format
|
|
by Accept header), backfill missing AVIF/WebP/JPEG derivatives on disk,
|
|
and drop the obsolete render-counter field ``version`` (invalidation is
|
|
an in-memory concern now, not database state)."""
|
|
|
|
def walk(nodes: dict) -> None:
|
|
for node in nodes.values():
|
|
for field in ("content", "banner"):
|
|
if isinstance(node.get(field), str):
|
|
node[field] = _DERIVATIVE_LINK.sub(r"\1", node[field])
|
|
walk(node.get("children") or {})
|
|
|
|
walk(d.get("menu") or {})
|
|
d.pop("version", None)
|
|
_backfill_derivatives()
|
|
|
|
|
|
def migrate_v3(d: dict) -> None:
|
|
"""Content-addressed chunk storage (docs/migrate.md): split every
|
|
node's string ``content`` into block chunks stored once per content
|
|
hash in the new ``chunks`` store; the node keeps the ordered hash
|
|
list as ``chunks`` (an absent content stays absent, i.e. None = a
|
|
pure category label; "" chunks to an empty list = an empty page).
|
|
|
|
Chunk keys are 9-byte blake3 digests; at this raw JSON level they are
|
|
base64 strings (decoding into the structs restores ``bytes`` keys).
|
|
``trans`` starts empty; the translator job fills it and maintains the
|
|
``langs`` index as translations land. ``language``, ``no_trans`` and
|
|
``langs`` need nothing — struct defaults cover them.
|
|
"""
|
|
store = d.setdefault("chunks", {})
|
|
d.setdefault("trans", {})
|
|
|
|
def walk(nodes: dict) -> None:
|
|
for node in nodes.values():
|
|
content = node.pop("content", None)
|
|
if isinstance(content, str):
|
|
hashes = []
|
|
for chunk in chunk_markdown(content):
|
|
key = base64.b64encode(chunk_key(chunk)).decode()
|
|
store.setdefault(key, chunk)
|
|
hashes.append(key)
|
|
node["chunks"] = hashes
|
|
walk(node.get("children") or {})
|
|
|
|
walk(d.get("menu") or {})
|