feat: add desktop file support
This commit is contained in:
@@ -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`.
|
||||
@@ -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 %}
|
||||
```
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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}")
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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))
|
||||
@@ -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,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
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user