feat: add desktop file support

This commit is contained in:
2026-09-17 15:02:21 +08:00
parent 7986c72ff0
commit f1bb0e2a0c
13 changed files with 538 additions and 3 deletions
+44
View File
@@ -0,0 +1,44 @@
# Desktop Render Context
This document describes the data that `metaglot desktop render` provides to a user-provided [Liquid](https://shopify.github.io/liquid/) template when rendering freedesktop desktop entry files.
MetaGlot reads templates as UTF-8 and writes the rendered file as UTF-8 (without BOM) using the system's native line endings. This encoding is a built-in constraint of the tool and is not configurable. Desktop entry files are UTF-8 by definition, so no encoding declaration is needed (see the `example` folder for a complete sample).
The renderer runs in strict mode: referencing a variable or a property that does not exist fails the render.
## Top-Level Variables
| Variable | Type | Description |
|---|---|---|
| `languages` | array of language | One entry per translation language, see below. |
| `default` | language | The manifest's source language entry, see below. |
The order of the `languages` entries is not guaranteed: they appear in the order their PO files are matched. The manifest's source language is **not** merged into the array (unlike the RC rendering, which always synthesizes it): `languages` holds exactly the languages of the provided PO files, and the source language is exposed separately as the top-level `default` variable, built directly from the manifest's source strings without reading any PO file. If a PO file for the source language is provided, its language appears in the array as a regular entry alongside `default`.
The language of a PO file is resolved from its `Language:` header, falling back to the file name without extension; only values valid as Gettext `Language` fields are accepted. Two PO files describing the same language, and PO entries unknown to the manifest, are rejected.
## Language Object
| Property | Type | Description |
|---|---|---|
| `name` | string | The language tag of the PO file, e.g. `en_US`, `zh_CN`. |
| `locale` | string | The desktop entry locale of the language, used as the `LOCALE` postfix of localized keys, e.g. `en_US`, `zh_CN`, `sr_RS@latin`. |
| `strings` | mapping of string to string | Maps every manifest entry key to its resolved text, e.g. `lang.strings.name`. |
The resolved text of an entry falls back to the manifest source string when the entry is untranslated, missing from the PO file, or marked fuzzy.
The `locale` property holds the value defined by the desktop entry specification for localized keys: the language and country codes carry over from the PO language unchanged, and the Gettext variant slot fills the locale's modifier slot (e.g. `sr_RS@latin` stays `sr_RS@latin`). Every valid PO language converts successfully; there is no "unmapped language" rejection (unlike RC rendering).
## Default Object
The top-level `default` is a language object of the same shape as the entries of `languages`. It describes the manifest's source language (`source_language`) and is always present. It is built directly from the manifest's source strings — never from PO files — and is not part of the `languages` array: iterating `languages` yields the translation languages only.
The desktop entry specification requires every localized key (e.g. `Name[zh_CN]`) to be accompanied by the same key without a locale postfix (e.g. `Name`). `default.strings` is the intended source for those unlocalized keys:
```liquid
Name={{ default.strings.name | desktop_escape }}
```
## Filters
All filters described in [filters.md](filters.md) are available when rendering desktop entry files: the general filters available in every rendering, and the desktop filters such as `desktop_escape`.
+14
View File
@@ -31,3 +31,17 @@ The following filters are available only when rendering Windows RC files.
| Filter | Description |
|---|---|
| `rc_escape` | Escapes a string for use inside a double-quoted C-style string literal, such as an RC string table entry: `\` becomes `\\` and `"` becomes `\"`. |
## Desktop Filters
The following filters are available only when rendering desktop entry files.
| Filter | Description |
|---|---|
| `desktop_escape` | Escapes a string for use as a desktop entry `string`, `localestring` or `iconstring` value: `\` becomes `\\`, newline becomes `\n`, carriage return becomes `\r` and tab becomes `\t` anywhere; leading and trailing spaces become `\s`. Semicolons are not escaped, because they separate the values of plural keys (e.g. `Keywords`); escape them with the standard `replace` filter when composing such values item by item. |
Examples:
```liquid
{{ "Example" | desktop_escape }} {% comment %}renders "Example"{% endcomment %}
```
+17
View File
@@ -0,0 +1,17 @@
[Desktop Entry]
Type=Application
Name={{ default.strings.name | desktop_escape }}
GenericName={{ default.strings.generic_name | desktop_escape }}
Comment={{ default.strings.comment | desktop_escape }}
Exec=example %F
Icon=org.example.Example
Terminal=false
StartupNotify=false
Categories=Graphics;
MimeType=image/bmp;image/jpeg;image/png;image/svg+xml;
Keywords={{ default.strings.keywords | desktop_escape }}
{% for lang in languages %}Name[{{ lang.locale }}]={{ lang.strings.name | desktop_escape }}
GenericName[{{ lang.locale }}]={{ lang.strings.generic_name | desktop_escape }}
Comment[{{ lang.locale }}]={{ lang.strings.comment | desktop_escape }}
Keywords[{{ lang.locale }}]={{ lang.strings.keywords | desktop_escape }}
{% endfor %}
+18
View File
@@ -0,0 +1,18 @@
version = 1
source_language = "en"
[strings.name]
msgid = "Example"
comment = "Display name of the application."
[strings.generic_name]
msgid = "Image Viewer"
comment = "Generic name describing the application category."
[strings.comment]
msgid = "A lightweight image viewer"
comment = "Tooltip-like description of the application."
[strings.keywords]
msgid = "Picture;Image;Viewer;"
comment = "Search keywords. Semicolon separated list; the trailing semicolon is required."
+192 -1
View File
@@ -46,16 +46,57 @@ class RcCommand(enum.StrEnum):
Update = "update"
@dataclass(frozen=True)
class DesktopPotOpts:
in_manifest: Path
"""The path to input manifest file including translation strings."""
out_pot: Path
"""The path to output POT file."""
@dataclass(frozen=True)
class DesktopRenderOpts:
in_manifest: Path
"""The path to input manifest file including translation strings."""
in_po: list[Path]
"""The path to input PO files to read, each may be a glob pattern."""
in_template: Path
"""The path to the input user-provided Liquid for rendering."""
out_desktop: Path
"""The path to the output rendered desktop entry file."""
@dataclass(frozen=True)
class DesktopUpdateOpts:
in_pot: Path
"""The path to the input POT template file."""
in_po: list[Path]
"""The path to input PO files to update, each may be a glob pattern."""
@dataclass(frozen=True)
class DesktopOpts:
opts: DesktopPotOpts | DesktopRenderOpts | DesktopUpdateOpts
"""The option of one of subcommand."""
class DesktopCommand(enum.StrEnum):
Pot = "pot"
Render = "render"
Update = "update"
@dataclass(frozen=True)
class Opts:
"""The root options."""
opts: RcOpts
opts: RcOpts | DesktopOpts
"""The option of one of subcommand."""
class Command(enum.StrEnum):
Rc = "rc"
Desktop = "desktop"
# endregion
@@ -64,6 +105,7 @@ class Command(enum.StrEnum):
_SUBCMD_DEST = "command"
_RC_SUBCMD_DEST = "rc_command"
_DESKTOP_SUBCMD_DEST = "desktop_command"
_RC_POT_MANIFEST_DEST = "manifest"
@@ -77,6 +119,17 @@ _RC_RDR_OUTPUT_DEST = "output"
_RC_UPD_POT_DEST = "pot"
_RC_UPD_PO_DEST = "po"
_DESKTOP_POT_MANIFEST_DEST = "manifest"
_DESKTOP_POT_OUTPUT_DEST = "output"
_DESKTOP_RDR_MANIFEST_DEST = "manifest"
_DESKTOP_RDR_PO_DEST = "po"
_DESKTOP_RDR_TEMPLATE_DEST = "template"
_DESKTOP_RDR_OUTPUT_DEST = "output"
_DESKTOP_UPD_POT_DEST = "pot"
_DESKTOP_UPD_PO_DEST = "po"
def _register_rc_pot_param(parser: ArgumentParser) -> None:
parser.add_argument(
@@ -186,12 +239,125 @@ def _register_rc_param(parser: ArgumentParser) -> None:
_register_rc_update_param(update_subcmd)
def _register_desktop_pot_param(parser: ArgumentParser) -> None:
parser.add_argument(
"-m",
"--manifest",
dest=_DESKTOP_POT_MANIFEST_DEST,
action="store",
type=Path,
required=True,
help="The path to input manifest file including translation strings.",
metavar="FILE",
)
parser.add_argument(
"-o",
"--output",
dest=_DESKTOP_POT_OUTPUT_DEST,
action="store",
type=Path,
required=True,
help="The path to output POT file.",
metavar="FILE",
)
def _register_desktop_render_param(parser: ArgumentParser) -> None:
parser.add_argument(
"-m",
"--manifest",
dest=_DESKTOP_RDR_MANIFEST_DEST,
action="store",
type=Path,
required=True,
help="The path to input manifest file including translation strings.",
metavar="FILE",
)
parser.add_argument(
"-p",
"--po",
dest=_DESKTOP_RDR_PO_DEST,
action="extend",
nargs="+",
type=Path,
required=True,
help="The PO files to read, each may be a glob pattern.",
metavar="GLOB",
)
parser.add_argument(
"-t",
"--template",
dest=_DESKTOP_RDR_TEMPLATE_DEST,
action="store",
type=Path,
required=True,
help="The path to the input user-provided Liquid for rendering.",
metavar="FILE",
)
parser.add_argument(
"-o",
"--output",
dest=_DESKTOP_RDR_OUTPUT_DEST,
action="store",
type=Path,
required=True,
help="The path to the output rendered desktop entry file.",
metavar="FILE",
)
def _register_desktop_update_param(parser: ArgumentParser) -> None:
parser.add_argument(
"-t",
"--pot",
dest=_DESKTOP_UPD_POT_DEST,
action="store",
type=Path,
required=True,
help="The path to the input POT template file.",
metavar="FILE",
)
parser.add_argument(
"-p",
"--po",
dest=_DESKTOP_UPD_PO_DEST,
action="extend",
nargs="+",
type=Path,
required=True,
help="The PO files to update, each may be a glob pattern.",
metavar="GLOB",
)
def _register_desktop_param(parser: ArgumentParser) -> None:
cmds = parser.add_subparsers(dest=_DESKTOP_SUBCMD_DEST, required=True)
pot_subcmd = cmds.add_parser(
DesktopCommand.Pot.value,
help="Generate a POT template from given manifest.",
)
_register_desktop_pot_param(pot_subcmd)
render_subcmd = cmds.add_parser(
DesktopCommand.Render.value,
help="Render a user-provided template with manifest data and PO translations.",
)
_register_desktop_render_param(render_subcmd)
update_subcmd = cmds.add_parser(
DesktopCommand.Update.value, help="Update PO files against a POT template."
)
_register_desktop_update_param(update_subcmd)
def _register_param(parser: ArgumentParser) -> None:
cmds = parser.add_subparsers(dest=_SUBCMD_DEST, required=True)
rc_subcmd = cmds.add_parser(
Command.Rc.value, help="Commands for Windows RC resource files."
)
_register_rc_param(rc_subcmd)
desktop_subcmd = cmds.add_parser(
Command.Desktop.value, help="Commands for freedesktop desktop entry files."
)
_register_desktop_param(desktop_subcmd)
# endregion
@@ -235,6 +401,31 @@ def parse() -> Opts:
case _:
raise ValueError(f"unhandled rc command: {args[_RC_SUBCMD_DEST]}")
opts = Opts(rc_opts)
case Command.Desktop:
match DesktopCommand(args[_DESKTOP_SUBCMD_DEST]):
case DesktopCommand.Pot:
desktop_pot_opts = DesktopPotOpts(
args[_DESKTOP_POT_MANIFEST_DEST], args[_DESKTOP_POT_OUTPUT_DEST]
)
desktop_opts = DesktopOpts(desktop_pot_opts)
case DesktopCommand.Render:
desktop_rdr_opts = DesktopRenderOpts(
args[_DESKTOP_RDR_MANIFEST_DEST],
args[_DESKTOP_RDR_PO_DEST],
args[_DESKTOP_RDR_TEMPLATE_DEST],
args[_DESKTOP_RDR_OUTPUT_DEST],
)
desktop_opts = DesktopOpts(desktop_rdr_opts)
case DesktopCommand.Update:
desktop_upd_opts = DesktopUpdateOpts(
args[_DESKTOP_UPD_POT_DEST], args[_DESKTOP_UPD_PO_DEST]
)
desktop_opts = DesktopOpts(desktop_upd_opts)
case _:
raise ValueError(
f"unhandled desktop command: {args[_DESKTOP_SUBCMD_DEST]}"
)
opts = Opts(desktop_opts)
case _:
raise ValueError(f"unhandled command: {args[_SUBCMD_DEST]}")
return opts
+4 -2
View File
@@ -1,9 +1,11 @@
from ..cli import Opts, RcOpts
from . import rc
from ..cli import Opts, RcOpts, DesktopOpts
from . import desktop, rc
def run(opts: Opts) -> None:
match opts.opts:
case RcOpts() as rc_opts:
rc.run(rc_opts)
case DesktopOpts() as desktop_opts:
desktop.run(desktop_opts)
case _:
raise RuntimeError(f"unhandled options: {opts.opts!r}")
+13
View File
@@ -0,0 +1,13 @@
from ...cli import DesktopOpts, DesktopPotOpts, DesktopRenderOpts, DesktopUpdateOpts
from . import pot, render, update
def run(opts: DesktopOpts) -> None:
match opts.opts:
case DesktopPotOpts() as pot_opts:
pot.run(pot_opts)
case DesktopRenderOpts() as render_opts:
render.run(render_opts)
case DesktopUpdateOpts() as update_opts:
update.run(update_opts)
case _:
raise RuntimeError(f"unhandled desktop options: {opts.opts!r}")
+7
View File
@@ -0,0 +1,7 @@
from ...cli import DesktopPotOpts
from ...manifest import load_manifest
from ...pofile import generate_pot
def run(opts: DesktopPotOpts) -> None:
manifest = load_manifest(opts.in_manifest)
generate_pot(manifest, opts.out_pot)
+43
View File
@@ -0,0 +1,43 @@
import logging
from ...cli import DesktopRenderOpts
from ...filters import register_desktop_filters, register_general_filters
from ...langmap import desktop_lang_map
from ...manifest import Manifest, load_manifest
from ...pofile import (
LanguagePack,
build_default_pack,
resolve_glob_files,
resolve_translations,
)
from ...render import create_environment, render
def _language_context(pack: LanguagePack, manifest: Manifest) -> dict:
locale = desktop_lang_map.convert(pack.lang)
return {
"name": pack.lang.value,
"locale": locale.value,
"strings": pack.translations,
}
def run(opts: DesktopRenderOpts) -> None:
manifest = load_manifest(opts.in_manifest)
packs = resolve_translations(manifest, resolve_glob_files(opts.in_po))
default = _language_context(
build_default_pack(manifest, manifest.source_language), manifest
)
entries = [_language_context(pack, manifest) for pack in packs.values()]
logging.info(
"rendering languages: %s (default %s)",
", ".join(entry["name"] for entry in entries),
default["name"],
)
env = create_environment()
register_general_filters(env)
register_desktop_filters(env)
render(
env, opts.in_template, {"languages": entries, "default": default},
opts.out_desktop,
)
+6
View File
@@ -0,0 +1,6 @@
from ...cli import DesktopUpdateOpts
from ...pofile import resolve_glob_files, update_po_files
def run(opts: DesktopUpdateOpts) -> None:
update_po_files(opts.in_pot, resolve_glob_files(opts.in_po))
+28
View File
@@ -40,3 +40,31 @@ def register_rc_filters(env: Environment) -> None:
:param env: The environment to register the filters on.
"""
env.add_filter("rc_escape", _rc_escape)
def _desktop_escape(value: str) -> str:
"""Escape a desktop entry string value (see the Desktop Entry
Specification, section 4 "Possible value types").
Backslash, newline, carriage return and tab are escaped everywhere.
Spaces are additionally escaped as ``\\s`` when leading or trailing,
where parsers may trim them away.
"""
core = (
value.replace("\\", "\\\\")
.replace("\n", "\\n")
.replace("\r", "\\r")
.replace("\t", "\\t")
)
lstripped = core.lstrip(" ")
leading = len(core) - len(lstripped)
trailing = len(lstripped) - len(lstripped.rstrip(" "))
return "\\s" * leading + lstripped.rstrip(" ") + "\\s" * trailing
def register_desktop_filters(env: Environment) -> None:
"""Register the Liquid filters specific to desktop entry file rendering.
:param env: The environment to register the filters on.
"""
env.add_filter("desktop_escape", _desktop_escape)
+127
View File
@@ -127,6 +127,133 @@ class PoLang:
# endregion
class DesktopLang:
"""
Represents a freedesktop.org desktop entry locale, i.e. the ``LOCALE``
postfix of a localized key (see the Desktop Entry Specification,
section 5 "Localized values for keys").
Supported formats:
ll - ISO 639 language code (2 or 3 letters, lowercase)
ll_CC - language code + ISO 3166 country code (uppercase)
ll@modifier - language code + modifier
ll_CC@modifier - language code + country code + modifier
The ``.ENCODING`` part of the specification's grammar is deliberately
not modeled: it is ignored when matching locales and never written by
real world desktop entry files.
Examples:
DesktopLang("en") # valid
DesktopLang("zh_CN") # valid
DesktopLang("sr@Latn") # valid (modifier without country)
DesktopLang("sr_RS@latin") # valid
DesktopLang("EN") # invalid (language code must be lowercase)
"""
__PATTERN: ClassVar[re.Pattern] = re.compile(
r"^(?P<language>[a-z]{2,3})"
r"(?:_(?P<country>[A-Z]{2}))?"
r"(?:@(?P<modifier>[a-zA-Z][a-zA-Z0-9-]*))?$"
)
__language_code: str
"""ISO 639 two-letter or three-letter language code (lowercase)."""
__country_code: Optional[str]
"""ISO 3166 two-letter country code (uppercase) or no presented."""
__modifier: Optional[str]
"""The modifier designator, such as 'latin' or 'Latn'."""
def __init__(self, value: str):
match = self.__PATTERN.match(value)
if not match:
raise ValueError(
f"Invalid desktop locale format: {value!r}. "
f"Expected 'll', 'll_CC', 'll@modifier' or 'll_CC@modifier'"
)
self.__language_code = match.group("language")
self.__country_code = match.group("country") # may be None
self.__modifier = match.group("modifier") # may be None
self.__validate_language(self.__language_code)
if self.__country_code:
self.__validate_country(self.__country_code)
# region: Internal validation helpers
@staticmethod
def __validate_language(language_code: str) -> None:
"""Validate the language code as an ISO 639 code using pycountry."""
lang = pycountry.languages.get(alpha_2=language_code)
if lang is None:
lang = pycountry.languages.get(alpha_3=language_code)
if lang is None:
raise ValueError(f"Invalid language code: {language_code!r}")
@staticmethod
def __validate_country(country_code: str) -> None:
"""Validate the country code as an ISO 3166 alpha-2 code using pycountry."""
country = pycountry.countries.get(alpha_2=country_code)
if country is None:
raise ValueError(f"Invalid country code: {country_code!r}")
# endregion
# region: Properties
@property
def language(self) -> str:
"""The lowercase language code."""
return self.__language_code
@property
def country(self) -> str | None:
"""The uppercase country code, or None if not specified."""
return self.__country_code
@property
def modifier(self) -> str | None:
"""The modifier identifier, or None if not specified."""
return self.__modifier
@property
def value(self) -> str:
"""The canonical string representation, rebuilt from the components."""
result = self.__language_code
if self.__country_code is not None:
result += f"_{self.__country_code}"
if self.__modifier is not None:
result += f"@{self.__modifier}"
return result
# endregion
# region: Object methods
def __str__(self) -> str:
return self.value
def __repr__(self) -> str:
return f"DesktopLang({self.value!r})"
def __eq__(self, other) -> bool:
if self is other:
return True
if not isinstance(other, DesktopLang):
return NotImplemented
return (
self.__language_code == other.__language_code
and self.__country_code == other.__country_code
and self.__modifier == other.__modifier
)
def __hash__(self) -> int:
return hash((self.__language_code, self.__country_code, self.__modifier))
# endregion
class WinLcid:
"""
Represents a Windows language identifier (LANGID), the 2-byte language
+25
View File
@@ -0,0 +1,25 @@
from ..langid import DesktopLang, PoLang
def convert(lang: PoLang) -> DesktopLang:
"""
Convert a PO file language to a desktop entry locale.
Both sides speak ISO codes and share the same component layout, so the
conversion maps components one to one: the language and country codes
carry over unchanged, and the PO variant slot fills the desktop
modifier slot. No lookup table is needed here - unlike the Windows
map, whose vocabulary differs on both sides ('cyrillic' != 'Cyrl') -
which keeps this module the extension point should special cases
emerge.
:param lang: The PO file language.
:return: The desktop entry locale for the language.
"""
segments = [lang.language]
if lang.country is not None:
segments.append(lang.country)
value = "_".join(segments)
if lang.variant is not None:
value += f"@{lang.variant}"
return DesktopLang(value)