diff --git a/doc/manifest.md b/doc/manifest.md new file mode 100644 index 0000000..5dc9bb2 --- /dev/null +++ b/doc/manifest.md @@ -0,0 +1,69 @@ +# String Manifest + +This document describes how to write a MetaGlot string manifest: the TOML file that +declares the translatable strings of an application's metadata. + +The manifest is platform-neutral. It only describes *what* can be translated, not how +translations end up in any particular platform format; MetaGlot reads it to produce +Gettext translation catalogs and to fill platform-native output files. The file must +be encoded in UTF-8. + +## Top-level fields + +| Field | Type | Description | +|-----------|--------|-------------| +| `version` | int | The manifest format version. Currently `1`; any other value is rejected. | +| `strings` | table | All translatable strings. Each item maps a string entry key to a string entry, see below. | + +## String entry + +| Field | Type | Description | +|-----------|--------|-------------| +| `msgid` | string | Required. The untranslated source string. | +| `context` | string | Optional. Disambiguates entries that share the same `msgid` but are used differently, mirroring the context (`msgctxt`) mechanism of Gettext. Defaults to empty. | +| `comment` | string | Optional. A note for translators explaining where and how the string is used. Defaults to empty. | + +The string entry key identifies the entry inside the manifest and in generated +outputs. It must be non-empty; both numeric IDs (e.g. `1000`) and names +(e.g. `file_description`) are acceptable, and numeric IDs are represented as strings. + +## Validation rules + +A manifest is rejected when: + +- `version` is not `1`. +- Any string entry key is empty. +- Two entries share both `msgid` and `context`. +- An entry or the document root contains unknown fields. + +## Example + +```toml +version = 1 + +[strings.panel_title] +msgid = "Example - Image Viewer" +context = "Explorer" +comment = "Shown by the file manager in the properties panel." + +[strings.file_type_jpeg] +msgid = "JPEG Image" +comment = "File type name for: jpg, jpeg, jfif" + +[strings.file_type_png] +msgid = "PNG Image" +comment = "File type name for: png" + +[strings.file_description] +msgid = "Example - Image Viewer" +context = "Self" +comment = "File description of the executable." + +[strings.product_name] +msgid = "Example" +comment = "Display name of the product." +``` + +Note that entries `panel_title` and `file_description` share the same `msgid`. This +is allowed because their `context` values differ; without distinct contexts the +combination would be rejected as ambiguous. diff --git a/src/metaglot/catalog.py b/src/metaglot/catalog.py index 58ef6a8..0066bc9 100644 --- a/src/metaglot/catalog.py +++ b/src/metaglot/catalog.py @@ -1,51 +1,7 @@ import glob -import tomllib from pathlib import Path import polib -from pydantic import BaseModel, ConfigDict, model_validator - - -class StringEntry(BaseModel): - model_config = ConfigDict(extra="forbid") - - msgid: str - context: str = "" - - -class Manifest(BaseModel): - model_config = ConfigDict(extra="forbid") - - strings: dict[str, StringEntry] - - @model_validator(mode="after") - def _check_entries(self) -> "Manifest": - empty_keys = sorted(key for key in self.strings if not key) - if empty_keys: - raise ValueError("string key(s) must not be empty") - msgids = [entry.msgid for entry in self.strings.values()] - duplicated_msgids = sorted({msgid for msgid in msgids if msgids.count(msgid) > 1}) - if duplicated_msgids: - raise ValueError(f"duplicated msgid(s): {', '.join(duplicated_msgids)}") - return self - - @property - def entries(self) -> list[tuple[str, StringEntry]]: - return list(self.strings.items()) - - @property - def msgids(self) -> set[str]: - return {entry.msgid for entry in self.strings.values()} - - -def load_manifest(path: Path) -> Manifest: - try: - raw = tomllib.loads(path.read_text(encoding="utf-8")) - except tomllib.TOMLDecodeError as exc: - raise ValueError(f"failed to parse manifest '{path}': {exc}") from exc - except OSError as exc: - raise RuntimeError(f"failed to read manifest '{path}': {exc}") from exc - return Manifest.model_validate(raw) def collect_po_files(patterns: list[Path]) -> list[Path]: diff --git a/src/metaglot/manifest.py b/src/metaglot/manifest.py new file mode 100644 index 0000000..6c405da --- /dev/null +++ b/src/metaglot/manifest.py @@ -0,0 +1,64 @@ +import tomllib +from pathlib import Path + +from pydantic import BaseModel, ConfigDict, field_validator, model_validator + +MANIFEST_VERSION = 1 +"""The manifest format version this build of MetaGlot understands.""" + + +class StringEntry(BaseModel): + model_config = ConfigDict(extra="forbid") + + msgid: str + context: str = "" + comment: str = "" + + +class Manifest(BaseModel): + model_config = ConfigDict(extra="forbid") + + version: int + strings: dict[str, StringEntry] + + @field_validator("version") + @classmethod + def _check_version(cls, value: int) -> int: + if value != MANIFEST_VERSION: + raise ValueError( + f"unsupported manifest version: {value} (expected {MANIFEST_VERSION})" + ) + return value + + @model_validator(mode="after") + def _check_entries(self) -> "Manifest": + empty_keys = sorted(key for key in self.strings if not key) + if empty_keys: + raise ValueError("string key(s) must not be empty") + pairs = [(entry.msgid, entry.context) for entry in self.strings.values()] + duplicated = sorted({pair for pair in pairs if pairs.count(pair) > 1}) + if duplicated: + rendered = ", ".join( + f"{msgid!r}" if not context else f"{msgid!r} (context {context!r})" + for msgid, context in duplicated + ) + raise ValueError(f"duplicated msgid and context combination(s): {rendered}") + return self + + @property + def entries(self) -> list[tuple[str, StringEntry]]: + return list(self.strings.items()) + + @property + def msgids(self) -> set[str]: + return {entry.msgid for entry in self.strings.values()} + + +def load_manifest(path: Path) -> Manifest: + try: + raw = tomllib.loads(path.read_text(encoding="utf-8")) + except tomllib.TOMLDecodeError as exc: + raise ValueError(f"failed to parse manifest '{path}': {exc}") from exc + except OSError as exc: + raise RuntimeError(f"failed to read manifest '{path}': {exc}") from exc + return Manifest.model_validate(raw) diff --git a/src/metaglot/pot.py b/src/metaglot/pot.py index 8c4677d..9ae8778 100644 --- a/src/metaglot/pot.py +++ b/src/metaglot/pot.py @@ -3,7 +3,7 @@ from pathlib import Path import polib -from .catalog import Manifest, load_manifest +from .manifest import Manifest, load_manifest _POT_HEADER = { "Project-Id-Version": "PACKAGE VERSION", @@ -21,7 +21,14 @@ def generate(manifest_path: Path, output_path: Path) -> None: po = polib.POFile() po.metadata = _POT_HEADER for _, entry in manifest.entries: - po.append(polib.POEntry(msgid=entry.msgid, msgstr="", comment=entry.context)) + po.append( + polib.POEntry( + msgid=entry.msgid, + msgstr="", + msgctxt=entry.context or None, + comment=entry.comment or None, + ) + ) output_path.parent.mkdir(parents=True, exist_ok=True) po.save(str(output_path)) logging.info("wrote %d entries to %s", len(po), output_path) diff --git a/src/metaglot/render.py b/src/metaglot/render.py index 22cff8c..d469b81 100644 --- a/src/metaglot/render.py +++ b/src/metaglot/render.py @@ -5,7 +5,8 @@ from liquid import Environment, StrictUndefined from liquid.exceptions import LiquidError from . import winlang -from .catalog import Manifest, collect_po_files, load_manifest, load_po +from .catalog import collect_po_files, load_po +from .manifest import Manifest, load_manifest def _rc_escape(value: str) -> str: