Source code for univention.l10n.umc

#!/usr/bin/python3
#
# Univention Management Console
"""
Each module definition contains the following entries:

* Module: The internal name of the module
* Python: A directory containing the Python module. There must be a subdirectory named like the internal name of the module.
* Definition: The |XML| definition of the module
* Javascript: The directory of the javascript code. In this directory must be a a file called :file:`<Module>.js`
* Category: The |XML| definition of additional categories
* Icons: A directory containing the icons used by the module. The directory structure must follow the following pattern :file:`<weight>x<height>/<icon>.(png|svg)`.

The entries Module and Definition are required.

Example::

    Module: ucr
    Python: umc/module
    Definition: umc/ucr.xml
    Javascript: umc/js
    Category: umc/categories/ucr.xml
    Icons: umc/icons
"""
#
# SPDX-FileCopyrightText: 2011-2025 Univention GmbH
# SPDX-License-Identifier: AGPL-3.0-only


import copy
import json
import os
import re
import subprocess
import sys
import warnings
import xml.etree.ElementTree as ET  # noqa: S405
from collections.abc import Iterable, Iterator
from email.utils import formatdate

import polib
from debian.deb822 import Deb822, Packages

from .helper import Error, call, make_parent_dir
from .message_catalogs import merge_po


MODULE = 'Module'
PYTHON = 'Python'
DEFINITION = 'Definition'
JAVASCRIPT = 'Javascript'
CATEGORY = 'Category'
ICONS = 'Icons'

LANGUAGES = ('de', )

PO_METADATA = {
    'Project-Id-Version': '',
    'Report-Msgid-Bugs-To': 'packages@univention.de',
    'POT-Creation-Date': '',
    'PO-Revision-Date': '',
    'Last-Translator': 'Univention GmbH <packages@univention.de>',
    'Language-Team': 'Univention GmbH <packages@univention.de>',
    'Language': '',
    'MIME-Version': '1.0',
    'Content-Type': 'text/plain; charset=UTF-8',
    'Content-Transfer-Encoding': '8bit',
}


[docs] class UMC_Module(dict): def __init__(self, *args): dict.__init__(self, *args) for key in (MODULE, PYTHON, JAVASCRIPT, DEFINITION, CATEGORY, ICONS): if self.get(key): self[key] = self[key][0] @property def package(self) -> str: """Return the name of the Debian binary package.""" return self['package'] @property def python_path(self) -> str | None: """Return path to Python UMC directory.""" try: return '%(Python)s/%(Module)s/' % self except KeyError: return None @property def js_path(self) -> str | None: """Return path to JavaScript UMC directory.""" try: return '%(Javascript)s/' % self except KeyError: return None @property def js_module_file(self) -> str | None: """Return path to main JavaScript file.""" try: return '%(Javascript)s/%(Module)s.js' % self except KeyError: return None def _iter_files(self, base: str | None, suffix: str) -> Iterator[str]: """Iterate over all files below base ending with suffix.""" if base is None: return for dirname, dirs, files in os.walk(base): # ignore .svn directories if '.svn' in dirs: dirs.remove('.svn') # we are only interested in .js files for ifile in files: if ifile.endswith(suffix): yield os.path.join(dirname, ifile) @property def js_files(self) -> Iterator[str]: """Iterate over all JavaScript UMC files.""" return self._iter_files(self.js_path, '.js') @property def html_files(self) -> Iterator[str]: """Iterate over all JavaScript HTML files.""" return self._iter_files(self.js_path, '.html') @property def css_files(self) -> Iterator[str]: """Iterate over all Javascript CSS files.""" return self._iter_files(self.js_path, '.css') @property def module_name(self) -> str | None: """Return the name of the UMC module.""" return self.__getitem__(MODULE) @property def xml_definition(self) -> str | None: """Return the path to the XML UMC definition.""" return self.get(DEFINITION) @property def xml_categories(self) -> str | None: """Return the path to the XML file defining categories.""" return self.get(CATEGORY) @property def python_files(self) -> Iterator[str]: """Iterate over all Python UMC files.""" return self._iter_files(self.python_path, '.py') @property def python_po_files(self) -> Iterator[str]: """Iterate over all Python UMC message catalogs.""" try: path = '%(Python)s/%(Module)s/' % self except KeyError: return for lang in LANGUAGES: yield os.path.join(path, '%s.po' % lang) @property def js_po_files(self) -> Iterator[str]: """Iterate over all JavaScript UMC message catalogs.""" path = self.get(JAVASCRIPT) if not path: # might be an empty string return for lang in LANGUAGES: yield os.path.join(path, '%s.po' % lang) @property def xml_po_files(self) -> Iterator[tuple[str, str]]: """Iterate over all XML UMC message catalogs.""" if self.xml_definition is None: return dirpath = os.path.dirname(self.xml_definition) for lang in LANGUAGES: path = os.path.join(dirpath, '%s.po' % lang) yield (lang, path) @property def icons(self) -> str | None: """Return path to UMC icon directory.""" return self.get(ICONS)
[docs] def read_modules(package: str, core: bool = False) -> list[UMC_Module]: """ Read |UMC| module definition from :file:`debian/<package>.umc-modules`. :param package: Name of the package. :param core: Import as core-module, e.g. the ones shipped with |UDM| itself. :returns: List of |UMC| module definitions. """ modules: list[UMC_Module] = [] file_umc_module = os.path.join('debian/', package + '.umc-modules') file_control = os.path.join('debian/control') if not os.path.isfile(file_umc_module): return modules provides = [] with open(file_control, encoding='utf-8') as fd_control: with warnings.catch_warnings(): # debian/deb822.py:982: UserWarning: cannot parse package relationship "${python3:Depends}", returning it raw for pkg in Packages.iter_paragraphs(fd_control): if pkg.get('Package') == package: provides = [p[0]['name'] for p in pkg.relations['provides']] break with open(file_umc_module, 'rb') as fd_umc: for item in Deb822.iter_paragraphs(fd_umc): item = {k: [v] for k, v in item.items()} # simulate dh_ucs.parseRfc822 behaviour # required fields if not core: for required in (MODULE, PYTHON, DEFINITION, JAVASCRIPT): if not item.get(required): raise Error('UMC module definition incomplete. key %s missing' % (required,)) # single values item['package'] = package item['provides'] = provides module = UMC_Module(item) if core and (module.module_name != 'umc-core' or not module.xml_categories): raise Error('Module definition does not match core module') modules.append(module) return modules
[docs] def module_xml2po(module: UMC_Module, po_file: str, language: str, template: bool = False) -> None: """ Create a PO file the |XML| definition of an |UMC| module. :param module: |UMC| module. :param po_file: File name of the textual message catalog. :param language: 2-letter language code. :param template: Keep PO template file. """ pot_file = '%s/messages.pot' % (os.path.dirname(po_file) or '.') po = polib.POFile(check_for_duplicates=True) po.metadata = copy.copy(PO_METADATA) po.metadata['Project-Id-Version'] = module.package po.metadata['POT-Creation-Date'] = formatdate(localtime=True) po.metadata['Language'] = language def _append_po_entry(xml_entry): """ Helper function to access text property of XML elements and to find the corresponding po-entry. """ if xml_entry is not None and xml_entry.text is not None: # important to use "xml_entry is not None"! entry = polib.POEntry(msgid=xml_entry.text, msgstr='') try: po.append(entry) except ValueError as exc: # Entry "..." already exists print('Warning: Appending %r to po file failed: %s' % (xml_entry.text, exc), file=sys.stderr) if module.xml_definition and os.path.isfile(module.xml_definition): tree = ET.ElementTree(file=module.xml_definition) _append_po_entry(tree.find('module/name')) _append_po_entry(tree.find('module/description')) _append_po_entry(tree.find('module/keywords')) for flavor in tree.findall('module/flavor'): _append_po_entry(flavor.find('name')) _append_po_entry(flavor.find('description')) _append_po_entry(flavor.find('keywords')) _append_po_entry(tree.find('link/name')) _append_po_entry(tree.find('link/description')) _append_po_entry(tree.find('link/url')) if module.xml_categories and os.path.isfile(module.xml_categories): tree = ET.ElementTree(file=module.xml_categories) for cat in tree.findall('categories/category'): _append_po_entry(cat.find('name')) po.save(pot_file) merge_po_file(po_file, pot_file) if not template: os.unlink(pot_file)
[docs] def create_po_file(po_file: str, package: str, files: str | Iterable[str], language: str = 'python', template: bool = False) -> None: """ Create a PO file for a defined set of files. :param po_file: File name of the textual message catalog. :param package: Name of the package. :param files: A single file name or a list of file names. :param language: Programming language name. :param template: Keep PO template file. """ pot_file = '%s/messages.pot' % (os.path.dirname(po_file) or '.') if os.path.isfile(pot_file): os.unlink(pot_file) if isinstance(files, str): files = [files] call( 'xgettext', '--force-po', '--add-comments=i18n', '--from-code=UTF-8', '--sort-output', '--package-name=%s' % package, '--msgid-bugs-address=packages@univention.de', '--copyright-holder=Univention GmbH', '--language', language, '--output', pot_file, *files, errmsg='xgettext failed for the files: %r' % (list(files),) # noqa: COM812 ) po = polib.pofile(pot_file) po.metadata['Content-Type'] = 'text/plain; charset=UTF-8' if po.metadata_is_fuzzy: # xgettext always creates fuzzy metadata try: po.metadata_is_fuzzy.remove('fuzzy') except ValueError: pass po.save() merge_po_file(po_file, pot_file) if not template: os.unlink(pot_file)
[docs] def merge_po_file(po_file: str, pot_file: str) -> None: """ Merge :file:`.po` file with new :file:`.pot` file. :param po_file: PO file containing translation. :param pot_file: PO template file. """ if os.path.isfile(po_file): merge_po(pot_file, po_file) else: call('cp', pot_file, po_file)
[docs] def create_mo_file(po_file: str, mo_file: str = '') -> None: """ Compile textual message catalog (`.po`) to binary message catalog (`.mo`). :param po_file: File name of the textual message catalog. :param mo_file: File name of compiled message catalog. """ if not mo_file: head, tail = os.path.splitext(po_file) assert tail == '.po' mo_file = head + '.mo' cmd = ('msgattrib', '--only-fuzzy', '--no-wrap', po_file) proc = subprocess.Popen(cmd, stdout=subprocess.PIPE) out, _err = proc.communicate() if out: raise Error("Error: '{}' contains 'fuzzy' translations:\n{}".format(po_file, out.decode('utf-8', 'replace'))) make_parent_dir(mo_file) call( 'msgfmt', '--check', '--output-file', mo_file, po_file, errmsg='Failed to compile translation file from %s.' % (po_file,), )
[docs] def create_json_file(po_file: str) -> None: """ Compile textual message catalog (`.po`) to |JSON| message catalog. :param po_file: File name of the textual message catalog. """ json_file = po_file.replace('.po', '.json') pofile = polib.pofile(po_file) data = {} has_plurals = False for meta_entry in pofile.ordered_metadata(): if meta_entry[0] == "Plural-Forms": has_plurals = True plural_rules = meta_entry[1] break # The rules get parsed from the pofile and put into the json file as # entries, if there are any. Parsing happens with regular expressions. if has_plurals: nplurals_start = re.search(r"nplurals\s*=\s*", plural_rules) nplurals_end = re.search(r"nplurals\s*=\s*[\d]+", plural_rules) # The $plural$ string contains everything from "plural=" to the last # ';'. This is a useful, since it would include illegal code, which # can then be found later and generate an error. plural_start = re.search(r"plural\s*=\s*", plural_rules) plural_end = re.search(r'plural\s*=.*;', plural_rules) if nplurals_start is None or nplurals_end is None or plural_start is None or plural_end is None: raise Error('The plural rules in %s\'s header entry "Plural-Forms" seem to be incorrect.' % (po_file)) data["$nplurals$"] = plural_rules[nplurals_start.end():nplurals_end.end()] data["$plural$"] = plural_rules[plural_start.end():plural_end.end() - 1] # The expression in data["$plural$"] will be evaluated via eval() in # javascript. To avoid malicious code injection a simple check is # performed here. if not re.match(r"^[\s\dn=?!&|%:()<>]+$", data["$plural$"]): raise Error('There are illegal characters in the "plural" expression in %s\'s header entry "Plural-Forms".' % (po_file)) for entry in pofile: if entry.msgstr: data[entry.msgid] = entry.msgstr elif entry.msgstr_plural and not has_plurals: raise Error("There are plural forms in %s, but no rules in the file's header." % (po_file)) elif entry.msgstr_plural: entries = entry.msgstr_plural.items() entries = sorted(entries, key=lambda x: int(x[0])) data[entry.msgid] = [x[1] for x in entries] if len(data[entry.msgid]) != int(data["$nplurals$"]): raise Error('The amount of plural forms for a translation in %s doesn\'t match "nplurals" from the file\'s header entry "Plural-Forms".' % (po_file)) with open(json_file, 'w') as fd: json.dump(data, fd)
[docs] def po_to_json(po_path: str, json_output_path: str) -> None: """ Convert translation file to `JSON` file. :param po_path: Translation file name. :param json_output_path: Output file name. """ create_json_file(po_path) make_parent_dir(json_output_path) os.rename(po_path.replace('.po', '.json'), json_output_path)