Localization #1
@@ -101,6 +101,10 @@ class Data(msgspec.Struct):
|
||||
#: linked as <link rel="icon"> on every page. Empty = the build's
|
||||
#: /favicon.ico.
|
||||
favicon: str = ""
|
||||
#: API key gating the translator service WebSocket (/_translate/{key};
|
||||
#: the external forward-auth does not cover that route). Generated
|
||||
#: lazily at startup when empty (see lifespan in app.py).
|
||||
translate_key: str = ""
|
||||
#: All original-language page text, content-addressed:
|
||||
#: chunk_key (9 bytes; base64 at the JSON level) -> Markdown chunk.
|
||||
#: Shared by every article.
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
"""Translator service protocol and its transport-independent core.
|
||||
|
||||
The external machine-translation service connects over WebSocket
|
||||
(``/_translate/<key>``, see app.py) and exchanges JSON frames decoded into
|
||||
the tagged msgspec structs below (``bytes`` fields ride as base64 — no
|
||||
manual encoding anywhere). This module holds the message structs plus the
|
||||
two computations shared by the WS handler: which fragments are pending for
|
||||
a language (``pending_items``) and storing a batch of results
|
||||
(``store_results``).
|
||||
"""
|
||||
|
||||
import msgspec
|
||||
|
||||
from pagerite.chunks import chunk_key, needs_translation
|
||||
from pagerite.data import Data, Node, sorted_nodes
|
||||
|
||||
|
||||
class Hello(msgspec.Struct, tag="hello"):
|
||||
"""Client greeting on connect: the target languages it handles."""
|
||||
|
||||
langs: list[str]
|
||||
|
||||
|
||||
class TransItem(msgspec.Struct):
|
||||
"""One fragment to translate: original Markdown (or a node title)."""
|
||||
|
||||
key: bytes #: 9-byte chunk hash (base64 in the JSON frame)
|
||||
text: str
|
||||
path: str #: article it came from ("" = front page), no leading slash
|
||||
kind: str #: "chunk" | "title"
|
||||
|
||||
|
||||
class Job(msgspec.Struct, tag="job"):
|
||||
"""Server push: pending items for one language."""
|
||||
|
||||
lang: str
|
||||
items: list[TransItem]
|
||||
|
||||
|
||||
class TransResult(msgspec.Struct):
|
||||
"""One translated fragment."""
|
||||
|
||||
key: bytes
|
||||
text: str
|
||||
|
||||
|
||||
class Result(msgspec.Struct, tag="result"):
|
||||
"""Client reply: a batch of translations for one language."""
|
||||
|
||||
lang: str
|
||||
items: list[TransResult]
|
||||
|
||||
|
||||
#: Union of the client -> server frames (the "type" tag selects).
|
||||
ClientMsg = Hello | Result
|
||||
|
||||
|
||||
def pending_items(data: Data, lang: str) -> list[TransItem]:
|
||||
"""Fragments of the site still untranslated for ``lang``, deduped by key.
|
||||
|
||||
Every page node (published or not) contributes its title and each chunk
|
||||
that needs translation (``needs_translation``), is not editor-flagged
|
||||
no-translate (``node.no_trans``) and has no ``trans`` entry for ``lang``
|
||||
yet. Content-addressed text (shared paragraphs, repeated titles) appears
|
||||
once, under the first page in menu order that has it.
|
||||
"""
|
||||
items: list[TransItem] = []
|
||||
seen: set[bytes] = set()
|
||||
|
||||
def emit(key: bytes, text: str, path: str, kind: str) -> None:
|
||||
if key in seen or lang in data.trans.get(key, {}):
|
||||
return
|
||||
seen.add(key)
|
||||
items.append(TransItem(key=key, text=text, path=path, kind=kind))
|
||||
|
||||
def walk(nodes: dict[str, Node], prefix: str) -> None:
|
||||
for slug, node in sorted_nodes(nodes):
|
||||
path = f"{prefix}/{slug}" if prefix else slug
|
||||
if node.chunks is not None:
|
||||
if node.title:
|
||||
emit(chunk_key(node.title), node.title, path, "title")
|
||||
for h in node.chunks:
|
||||
text = data.chunks.get(h)
|
||||
if (
|
||||
text is not None
|
||||
and h not in node.no_trans
|
||||
and needs_translation(text)
|
||||
):
|
||||
emit(h, text, path, "chunk")
|
||||
walk(node.children, path)
|
||||
|
||||
walk(data.menu, "")
|
||||
return items
|
||||
|
||||
|
||||
def store_results(data: Data, lang: str, items: list[TransResult]) -> list[str]:
|
||||
"""Store a batch of machine translations for ``lang``; return the paths
|
||||
of the articles that gained at least one entry.
|
||||
|
||||
Pure data operations: the caller wraps this in a kanta transaction and
|
||||
invalidates pages. Unknown keys are stored anyway (unreferenced hashes
|
||||
are never read, and the content may simply have moved on since the job
|
||||
was pushed); duplicates within a batch overwrite, last wins. Every
|
||||
article that gained an entry gets ``node.langs[lang]`` set (the
|
||||
availability index, docs/migrate.md) — because chunks are
|
||||
content-addressed, that includes pages merely sharing a fragment.
|
||||
"""
|
||||
stored = {item.key for item in items}
|
||||
for item in items:
|
||||
data.trans.setdefault(item.key, {})[lang] = item.text
|
||||
pages: list[str] = []
|
||||
|
||||
def walk(nodes: dict[str, Node], prefix: str) -> None:
|
||||
for slug, node in sorted_nodes(nodes):
|
||||
path = f"{prefix}/{slug}" if prefix else slug
|
||||
if node.chunks is not None:
|
||||
keys = set(node.chunks)
|
||||
if node.title:
|
||||
keys.add(chunk_key(node.title))
|
||||
if keys & stored:
|
||||
node.langs[lang] = True
|
||||
pages.append(path)
|
||||
walk(node.children, path)
|
||||
|
||||
walk(data.menu, "")
|
||||
return pages
|
||||
Reference in New Issue
Block a user