refactor: strip manifest out from catalog

This commit is contained in:
2026-09-14 10:46:57 +08:00
parent a392894196
commit 743d5a98b9
5 changed files with 144 additions and 47 deletions
+69
View File
@@ -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.
-44
View File
@@ -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]:
+64
View File
@@ -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)
+9 -2
View File
@@ -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)
+2 -1
View File
@@ -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: