From f1bb0e2a0c93ffd8aefab8d27690c11ba9695435 Mon Sep 17 00:00:00 2001 From: yyc12345 Date: Thu, 17 Sep 2026 15:02:21 +0800 Subject: [PATCH] feat: add desktop file support --- doc/desktop-render-context.md | 44 ++++++ doc/filters.md | 14 ++ example/example.desktop.liquid | 17 ++ example/example.desktop.toml | 18 +++ src/metaglot/cli.py | 193 ++++++++++++++++++++++- src/metaglot/cmds/__init__.py | 6 +- src/metaglot/cmds/desktop/__init__.py | 13 ++ src/metaglot/cmds/desktop/pot.py | 7 + src/metaglot/cmds/desktop/render.py | 43 +++++ src/metaglot/cmds/desktop/update.py | 6 + src/metaglot/filters.py | 28 ++++ src/metaglot/langid.py | 127 +++++++++++++++ src/metaglot/langmap/desktop_lang_map.py | 25 +++ 13 files changed, 538 insertions(+), 3 deletions(-) create mode 100644 doc/desktop-render-context.md create mode 100644 example/example.desktop.liquid create mode 100644 example/example.desktop.toml create mode 100644 src/metaglot/cmds/desktop/__init__.py create mode 100644 src/metaglot/cmds/desktop/pot.py create mode 100644 src/metaglot/cmds/desktop/render.py create mode 100644 src/metaglot/cmds/desktop/update.py create mode 100644 src/metaglot/langmap/desktop_lang_map.py diff --git a/doc/desktop-render-context.md b/doc/desktop-render-context.md new file mode 100644 index 0000000..b16296b --- /dev/null +++ b/doc/desktop-render-context.md @@ -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`. diff --git a/doc/filters.md b/doc/filters.md index 58bc98b..1556e31 100644 --- a/doc/filters.md +++ b/doc/filters.md @@ -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 %} +``` diff --git a/example/example.desktop.liquid b/example/example.desktop.liquid new file mode 100644 index 0000000..0fa5848 --- /dev/null +++ b/example/example.desktop.liquid @@ -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 %} diff --git a/example/example.desktop.toml b/example/example.desktop.toml new file mode 100644 index 0000000..5e0c3b7 --- /dev/null +++ b/example/example.desktop.toml @@ -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." diff --git a/src/metaglot/cli.py b/src/metaglot/cli.py index 22d2637..795ed4d 100644 --- a/src/metaglot/cli.py +++ b/src/metaglot/cli.py @@ -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 diff --git a/src/metaglot/cmds/__init__.py b/src/metaglot/cmds/__init__.py index 5bc0913..876a8fc 100644 --- a/src/metaglot/cmds/__init__.py +++ b/src/metaglot/cmds/__init__.py @@ -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}") diff --git a/src/metaglot/cmds/desktop/__init__.py b/src/metaglot/cmds/desktop/__init__.py new file mode 100644 index 0000000..097fba6 --- /dev/null +++ b/src/metaglot/cmds/desktop/__init__.py @@ -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}") diff --git a/src/metaglot/cmds/desktop/pot.py b/src/metaglot/cmds/desktop/pot.py new file mode 100644 index 0000000..826e73b --- /dev/null +++ b/src/metaglot/cmds/desktop/pot.py @@ -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) diff --git a/src/metaglot/cmds/desktop/render.py b/src/metaglot/cmds/desktop/render.py new file mode 100644 index 0000000..88c9f27 --- /dev/null +++ b/src/metaglot/cmds/desktop/render.py @@ -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, + ) diff --git a/src/metaglot/cmds/desktop/update.py b/src/metaglot/cmds/desktop/update.py new file mode 100644 index 0000000..9fab6d6 --- /dev/null +++ b/src/metaglot/cmds/desktop/update.py @@ -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)) diff --git a/src/metaglot/filters.py b/src/metaglot/filters.py index a779677..1803cfb 100644 --- a/src/metaglot/filters.py +++ b/src/metaglot/filters.py @@ -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) diff --git a/src/metaglot/langid.py b/src/metaglot/langid.py index f9c1057..3f7b7cc 100644 --- a/src/metaglot/langid.py +++ b/src/metaglot/langid.py @@ -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[a-z]{2,3})" + r"(?:_(?P[A-Z]{2}))?" + r"(?:@(?P[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 diff --git a/src/metaglot/langmap/desktop_lang_map.py b/src/metaglot/langmap/desktop_lang_map.py new file mode 100644 index 0000000..38ea6e2 --- /dev/null +++ b/src/metaglot/langmap/desktop_lang_map.py @@ -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)