From f562937e81a7221c6aed229510aa12478960168c Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Sun, 31 Oct 2021 22:43:00 +0100 Subject: [PATCH] Add pylupdate6 tool to i18n (#920) * Add the pylupdate code from PyQt6 source * Fix linting errors in pylupdate6 * Update setup script, add readme and update debian copyright file --- i18n/pylupdate6/README.md | 7 + i18n/pylupdate6/__init__.py | 22 ++ i18n/pylupdate6/lupdate.py | 90 +++++++ i18n/pylupdate6/python_source.py | 362 ++++++++++++++++++++++++++ i18n/pylupdate6/source_file.py | 29 +++ i18n/pylupdate6/translation_file.py | 390 ++++++++++++++++++++++++++++ i18n/pylupdate6/translations.py | 51 ++++ i18n/pylupdate6/user.py | 47 ++++ setup.py | 20 +- setup/debian/copyright | 16 ++ 10 files changed, 1027 insertions(+), 7 deletions(-) create mode 100644 i18n/pylupdate6/README.md create mode 100644 i18n/pylupdate6/__init__.py create mode 100644 i18n/pylupdate6/lupdate.py create mode 100644 i18n/pylupdate6/python_source.py create mode 100644 i18n/pylupdate6/source_file.py create mode 100644 i18n/pylupdate6/translation_file.py create mode 100644 i18n/pylupdate6/translations.py create mode 100644 i18n/pylupdate6/user.py diff --git a/i18n/pylupdate6/README.md b/i18n/pylupdate6/README.md new file mode 100644 index 00000000..32ffae95 --- /dev/null +++ b/i18n/pylupdate6/README.md @@ -0,0 +1,7 @@ +# PyQt6 Linguist Tool + +This is a copy of the `pylupdate` tool from PyQt6 version 6.2.1 from +[PyPi](https://pypi.org/project/PyQt6/). +The tool is stripped down to run for novelWriter. + +PyQt6 is licensed under GPL 3.0 diff --git a/i18n/pylupdate6/__init__.py b/i18n/pylupdate6/__init__.py new file mode 100644 index 00000000..698832fc --- /dev/null +++ b/i18n/pylupdate6/__init__.py @@ -0,0 +1,22 @@ +# Copyright (c) 2021 Riverbank Computing Limited +# +# This file is part of PyQt6. +# +# This file may be used under the terms of the GNU General Public License +# version 3.0 as published by the Free Software Foundation and appearing in +# the file LICENSE included in the packaging of this file. Please review the +# following information to ensure the GNU General Public License version 3.0 +# requirements will be met: http://www.gnu.org/copyleft/gpl.html. +# +# If you do not wish to use this file under the terms of the GPL version 3.0 +# then you may purchase a commercial license. For more information contact +# info@riverbankcomputing.com. +# +# This file is provided AS IS with NO WARRANTY OF ANY KIND, INCLUDING THE +# WARRANTY OF DESIGN, MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. + + +# The public API. +from .lupdate import lupdate + +__all__ = ["lupdate"] diff --git a/i18n/pylupdate6/lupdate.py b/i18n/pylupdate6/lupdate.py new file mode 100644 index 00000000..c624256a --- /dev/null +++ b/i18n/pylupdate6/lupdate.py @@ -0,0 +1,90 @@ +# Copyright (c) 2021 Riverbank Computing Limited +# +# This file is part of PyQt6. +# +# This file may be used under the terms of the GNU General Public License +# version 3.0 as published by the Free Software Foundation and appearing in +# the file LICENSE included in the packaging of this file. Please review the +# following information to ensure the GNU General Public License version 3.0 +# requirements will be met: http://www.gnu.org/copyleft/gpl.html. +# +# If you do not wish to use this file under the terms of the GPL version 3.0 +# then you may purchase a commercial license. For more information contact +# info@riverbankcomputing.com. +# +# This file is provided AS IS with NO WARRANTY OF ANY KIND, INCLUDING THE +# WARRANTY OF DESIGN, MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. + + +import fnmatch +import os + +# from .designer_source import DesignerSource +from .python_source import PythonSource +from .translation_file import TranslationFile +from .user import UserException + + +def lupdate( + sources, translation_files, no_obsolete=False, no_summary=True, verbose=False, excludes=None +): + """ Update a sequence of translation (.ts) files from a sequence of Python + source (.py) files, Designer source (.ui) files or directories containing + source files. + """ + + if excludes is None: + excludes = () + + # Read the .ts files. + translations = [ + TranslationFile(ts, no_obsolete=no_obsolete, no_summary=no_summary, verbose=verbose) + for ts in translation_files + ] + + # Read the sources. + source_files = [] + for source in sources: + if os.path.isdir(source): + for dirpath, _, filenames in os.walk(source): + for fn in filenames: + # Apply any exclusion patterns. + for exclude in excludes: + if fnmatch.fnmatch(fn, exclude): + fn = None + break + + if fn is None: + continue + + filename = os.path.join(dirpath, fn) + + if filename.endswith('.py'): + source_files.append(PythonSource(filename=filename, verbose=verbose)) + + # UI file support is disabled + # elif filename.endswith('.ui'): + # source_files.append( + # DesignerSource(filename=filename, + # verbose=verbose)) + + elif verbose: + print("Ignoring", filename) + + elif source.endswith('.py'): + source_files.append(PythonSource(filename=source, verbose=verbose)) + + # UI file support is disabled + # elif source.endswith('.ui'): + # source_files.append( + # DesignerSource(filename=source, verbose=verbose)) + + else: + raise UserException("{0} must be a directory or a .py or a .ui file".format(source)) + + # Update each translation for each source. + for t in translations: + for s in source_files: + t.update(s) + + t.write() diff --git a/i18n/pylupdate6/python_source.py b/i18n/pylupdate6/python_source.py new file mode 100644 index 00000000..6e01895c --- /dev/null +++ b/i18n/pylupdate6/python_source.py @@ -0,0 +1,362 @@ +# Copyright (c) 2021 Riverbank Computing Limited +# +# This file is part of PyQt6. +# +# This file may be used under the terms of the GNU General Public License +# version 3.0 as published by the Free Software Foundation and appearing in +# the file LICENSE included in the packaging of this file. Please review the +# following information to ensure the GNU General Public License version 3.0 +# requirements will be met: http://www.gnu.org/copyleft/gpl.html. +# +# If you do not wish to use this file under the terms of the GPL version 3.0 +# then you may purchase a commercial license. For more information contact +# info@riverbankcomputing.com. +# +# This file is provided AS IS with NO WARRANTY OF ANY KIND, INCLUDING THE +# WARRANTY OF DESIGN, MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. + + +import ast +import re +import tokenize + +from .source_file import SourceFile +from .translations import Context, EmbeddedComments, Message +from .user import User, UserException + + +class PythonSource(SourceFile, User): + """ Encapsulate a Python source file. """ + + # The regular expression to extract a PEP 263 encoding. + _PEP_263 = re.compile(rb'^[ \t\f]*#.*?coding[:=][ \t]*([-_.a-zA-Z0-9]+)') + + def __init__(self, **kwargs): + """ Initialise the object. """ + + super().__init__(**kwargs) + + # Read the source file. + self.progress("Reading {0}...".format(self.filename)) + with open(self.filename, 'rb') as f: + source = f.read() + + # Implement universal newlines. + source = source.replace(b'\r\n', b'\n').replace(b'\r', b'\n') + + # Try and extract a PEP 263 encoding. + encoding = 'UTF-8' + + for line_nr, line in enumerate(source.split(b'\n')): + if line_nr > 1: + break + + match = re.match(self._PEP_263, line) + if match: + encoding = match.group(1).decode('ascii') + break + + # Decode the source according to the encoding. + try: + source = source.decode(encoding) + except LookupError: + raise UserException("Unsupported encoding '{0}'".format(encoding)) + + # Parse the source file. + self.progress("Parsing {0}...".format(self.filename)) + + try: + tree = ast.parse(source, filename=self.filename) + except SyntaxError as e: + raise UserException( + "Invalid syntax at line {0} of {1}:\n{2}".format( + e.lineno, e.filename, e.text.rstrip() + ) + ) + + # Look for translation contexts and their contents. + visitor = Visitor(self) + visitor.visit(tree) + + # Read the file again as a sequence of tokens so that we see the + # comments. + with open(self.filename, 'rb') as f: + current = None + + for token in tokenize.tokenize(f.readline): + if token.type == tokenize.COMMENT: + # See if it is an embedded comment. + parts = token.string.split(' ', maxsplit=1) + if len(parts) == 2: + if parts[0] == '#:': + if current is None: + current = EmbeddedComments() + + current.extra_comments.append(parts[1]) + elif parts[0] == '#=': + if current is None: + current = EmbeddedComments() + + current.message_id = parts[1] + elif parts[0] == '#~': + parts = parts[1].split(' ', maxsplit=1) + if len(parts) == 1: + parts.append('') + + if current is None: + current = EmbeddedComments() + + current.extras.append(parts) + + elif token.type == tokenize.NL: + continue + + elif current is not None: + # Associate the embedded comment with the line containing + # this token. + line_nr = token.start[0] + + # See if there is a message on that line. + for context in self.contexts: + for message in context.messages: + if message.line_nr == line_nr: + break + else: + message = None + + if message is not None: + message.embedded_comments = current + break + + current = None + + +class Visitor(ast.NodeVisitor): + """ A visitor that extracts translation contexts. """ + + def __init__(self, source): + """ Initialise the visitor. """ + + self._source = source + self._context_stack = [] + + super().__init__() + + def visit_Call(self, node): + """ Visit a call. """ + + # Parse the arguments if a translation function is being called. + call_args = None + + if isinstance(node.func, ast.Attribute): + name = node.func.attr + + if name == 'tr': + call_args = self._parse_tr(node) + elif name == 'translate': + call_args = self._parse_translate(node) + + elif isinstance(node.func, ast.Name): + name = node.func.id + + if name == 'QT_TR_NOOP': + call_args = self._parse_QT_TR_NOOP(node) + elif name == 'QT_TRANSLATE_NOOP': + call_args = self._parse_QT_TRANSLATE_NOOP(node) + + # Update the context if the arguments are usable. + if call_args is not None and call_args.source != '': + call_args.context.messages.append( + Message( + self._source.filename, node.lineno, + call_args.source, call_args.disambiguation, + (call_args.numerus) + ) + ) + + self.generic_visit(node) + + def visit_ClassDef(self, node): + """ Visit a class. """ + + try: + name = self._context_stack[-1].name + '.' + node.name + except IndexError: + name = node.name + + self._context_stack.append(Context(name)) + + self.generic_visit(node) + + context = self._context_stack.pop() + + if context.messages: + self._source.contexts.append(context) + + def _get_current_context(self): + """ Return the current Context object if there is one. """ + + return self._context_stack[-1] if self._context_stack else None + + @classmethod + def _get_first_str(cls, args): + """ Get the first of a list of arguments as a str. """ + + # Check that there is at least one argument. + if not args: + return None + + return cls._get_str(args[0]) + + def _get_or_create_context(self, name): + """ Return the Context object for a name, creating it if necessary. """ + + for context in self._source.contexts: + if context.name == name: + return context + + context = Context(name) + self._source.contexts.append(context) + + return context + + @staticmethod + def _get_str(node, allow_none=False): + """ Return the str from a node or None if it wasn't an appropriate + node. + """ + + if isinstance(node, ast.Str): + return node.s + + if isinstance(node, ast.Constant): + if isinstance(node.value, str): + return node.value + + if allow_none and node.value is None: + return '' + + return None + + def _parse_QT_TR_NOOP(self, node): + """ Parse the arguments to QT_TR_NOOP(). """ + + # Ignore unless there is a current context. + context = self._get_current_context() + if context is None: + return None + + call_args = self._parse_noop_without_context(node.args, node.keywords) + if call_args is None: + return None + + call_args.context = context + + return call_args + + def _parse_QT_TRANSLATE_NOOP(self, node): + """ Parse the arguments to QT_TRANSLATE_NOOP(). """ + + # Get the context. + name = self._get_first_str(node.args) + if name is None: + return None + + call_args = self._parse_noop_without_context(node.args[1:], node.keywords) + if call_args is None: + return None + + call_args.context = self._get_or_create_context(name) + + return call_args + + def _parse_tr(self, node): + """ Parse the arguments to tr(). """ + + # Ignore unless there is a current context. + context = self._get_current_context() + if context is None: + return None + + call_args = self._parse_without_context(node.args, node.keywords) + if call_args is None: + return None + + call_args.context = context + + return call_args + + def _parse_translate(self, node): + """ Parse the arguments to translate(). """ + + # Get the context. + name = self._get_first_str(node.args) + if name is None: + return None + + call_args = self._parse_without_context(node.args[1:], node.keywords) + if call_args is None: + return None + + call_args.context = self._get_or_create_context(name) + + return call_args + + def _parse_without_context(self, args, keywords): + """ Parse arguments for a message source and optional disambiguation + and n. + """ + + # The source is required. + source = self._get_first_str(args) + if source is None: + return None + + if len(args) > 1: + disambiguation = self._get_str(args[1], allow_none=True) + else: + for kw in keywords: + if kw.arg == 'disambiguation': + disambiguation = self._get_str(kw.value, allow_none=True) + break + else: + disambiguation = '' + + # Ignore if the disambiguation is specified but isn't a string. + if disambiguation is None: + return None + + if len(args) > 2: + numerus = True + else: + numerus = 'n' in keywords + + if len(args) > 3: + return None + + return CallArguments(source, disambiguation, numerus) + + def _parse_noop_without_context(self, args, keywords): + """ Parse arguments for a message source. """ + + # There must be exactly one positional argument. + if len(args) != 1 or len(keywords) != 0: + return None + + source = self._get_str(args[0]) + if source is None: + return None + + return CallArguments(source) + + +class CallArguments: + """ Encapsulate the possible arguments of a translation function. """ + + def __init__(self, source, disambiguation='', numerus=False): + """ Initialise the object. """ + + self.context = None + self.source = source + self.disambiguation = disambiguation + self.numerus = numerus diff --git a/i18n/pylupdate6/source_file.py b/i18n/pylupdate6/source_file.py new file mode 100644 index 00000000..3cbbea29 --- /dev/null +++ b/i18n/pylupdate6/source_file.py @@ -0,0 +1,29 @@ +# Copyright (c) 2021 Riverbank Computing Limited +# +# This file is part of PyQt6. +# +# This file may be used under the terms of the GNU General Public License +# version 3.0 as published by the Free Software Foundation and appearing in +# the file LICENSE included in the packaging of this file. Please review the +# following information to ensure the GNU General Public License version 3.0 +# requirements will be met: http://www.gnu.org/copyleft/gpl.html. +# +# If you do not wish to use this file under the terms of the GPL version 3.0 +# then you may purchase a commercial license. For more information contact +# info@riverbankcomputing.com. +# +# This file is provided AS IS with NO WARRANTY OF ANY KIND, INCLUDING THE +# WARRANTY OF DESIGN, MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. + + +class SourceFile: + """ The base class for any source file that provides translation contexts. + """ + + def __init__(self, filename, **kwargs): + """ Initialise the object. """ + + super().__init__(**kwargs) + + self.filename = filename + self.contexts = [] diff --git a/i18n/pylupdate6/translation_file.py b/i18n/pylupdate6/translation_file.py new file mode 100644 index 00000000..ba71513c --- /dev/null +++ b/i18n/pylupdate6/translation_file.py @@ -0,0 +1,390 @@ +# Copyright (c) 2021 Riverbank Computing Limited +# +# This file is part of PyQt6. +# +# This file may be used under the terms of the GNU General Public License +# version 3.0 as published by the Free Software Foundation and appearing in +# the file LICENSE included in the packaging of this file. Please review the +# following information to ensure the GNU General Public License version 3.0 +# requirements will be met: http://www.gnu.org/copyleft/gpl.html. +# +# If you do not wish to use this file under the terms of the GPL version 3.0 +# then you may purchase a commercial license. For more information contact +# info@riverbankcomputing.com. +# +# This file is provided AS IS with NO WARRANTY OF ANY KIND, INCLUDING THE +# WARRANTY OF DESIGN, MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. + + +import os +from xml.etree import ElementTree + +from .user import User, UserException + + +class TranslationFile(User): + """ Encapsulate a translation file. """ + + def __init__(self, ts_file, no_obsolete, no_summary, **kwargs): + """ Initialise the translation file. """ + + super().__init__(**kwargs) + + if os.path.isfile(ts_file): + self.progress("Reading {0}...".format(ts_file)) + + try: + self._root = ElementTree.parse(ts_file).getroot() + except Exception as e: + raise UserException( + "{}: {}: {}".format(ts_file, "invalid translation file", str(e)) + ) + else: + self._root = ElementTree.fromstring(_EMPTY_TS) + + self._ts_file = ts_file + self._no_obsolete = no_obsolete + self._no_summary = no_summary + self._updated_contexts = {} + + # Create a dict of contexts keyed by the context name and having the + # list of message elements as the value. + self._contexts = {} + + # Also create a dict of existing translations so that they can be + # re-used. + self._translations = {} + + context_els = [] + for context_el in self._root: + if context_el.tag != 'context': + continue + + context_els.append(context_el) + + name = '' + message_els = [] + + for el in context_el: + if el.tag == 'name': + name = el.text + elif el.tag == 'message': + message_els.append(el) + + if name: + self._contexts[name] = message_els + + for message_el in message_els: + source_el = message_el.find('source') + if source_el is None or not source_el.text: + continue + + translation_el = message_el.find('translation') + if translation_el is None or not translation_el.text: + continue + + self._translations[source_el.text] = translation_el.text + + # Remove the context elements but keep everything else in the root + # (probably set by Linguist). + for context_el in context_els: + self._root.remove(context_el) + + # Clear the summary statistics. + self._nr_new = 0 + self._nr_new_duplicates = 0 + self._nr_new_using_existing_translation = 0 + self._nr_existing = 0 + self._nr_kept_obsolete = 0 + self._nr_discarded_obsolete = 0 + self._nr_discarded_untranslated = 0 + + # Remember all new messages so we can make the summary less confusing + # than it otherwise might be. + self._new_message_els = [] + + def update(self, source): + """ Update the translation file from a SourceFile object. """ + + self.progress("Updating {0} from {1}...".format(self._ts_file, source.filename)) + + for context in source.contexts: + # Get the messages that we already know about for this context. + try: + message_els = self._contexts[context.name] + except KeyError: + message_els = [] + + # Get the messages that have already been updated. + updated_message_els = self._get_updated_message_els(context.name) + + for message in context.messages: + message_el = self._find_message(message, message_els) + + if message_el is not None: + # Move the message to the updated list. + message_els.remove(message_el) + self._add_message_el(message_el, updated_message_els) + else: + # See if this is a new message. If not then we just have + # another location for an existing message. + message_el = self._find_message(message, updated_message_els) + + if message_el is None: + message_el = self._make_message_el(message) + updated_message_els.append(message_el) + + self.progress("Added new message '{0}'".format(self.pretty(message.source))) + self._nr_new += 1 + else: + self.progress("Updated message '{0}'".format(self.pretty(message.source))) + + # Don't count another copy of a new message as an existing + # one. + if message_el in self._new_message_els: + self._nr_new_duplicates += 1 + else: + self._nr_existing += 1 + + message_el.insert(0, self._make_location_el(message)) + + def write(self): + """ Write the translation file back to the filesystem. """ + + # If we are keeping obsolete messages then add them to the updated + # message elements list. + for name, message_els in self._contexts.items(): + updated_message_els = None + + for message_el in message_els: + source = self.pretty(message_el.find('source').text) + + translation_el = message_el.find('translation') + if translation_el is not None and translation_el.text: + if self._no_obsolete: + self.progress("Discarded obsolete message '{0}'".format(source)) + self._nr_discarded_obsolete += 1 + else: + translation_el.set('type', 'vanished') + + if updated_message_els is None: + updated_message_els = self._get_updated_message_els(name) + + self._add_message_el(message_el, updated_message_els) + + self.progress("Kept obsolete message '{0}'".format(source)) + self._nr_kept_obsolete += 1 + else: + self.progress("Discarded untranslated message '{0}'".format(source)) + self._nr_discarded_untranslated += 1 + + # Created the sorted context elements. + for name in sorted(self._updated_contexts.keys()): + context_el = ElementTree.Element('context') + + name_el = ElementTree.Element('name') + name_el.text = name + context_el.append(name_el) + + context_el.extend(self._updated_contexts[name]) + + self._root.append(context_el) + + self.progress("Writing {0}...".format(self._ts_file)) + with open(self._ts_file, 'w', encoding='utf-8', newline='\n') as f: + f.write('\n') + f.write('\n') + + # Python v3.9 and later. + if hasattr(ElementTree, 'indent'): + ElementTree.indent(self._root) + + ElementTree.ElementTree(self._root).write(f, encoding='unicode') + f.write('\n') + + if not self._no_summary: + self._summary() + + @staticmethod + def _add_message_el(message_el, updated_message_els): + """ Add a message element to a list of updated message elements. """ + + # Remove all the location elements. + for location_el in message_el.findall('location'): + message_el.remove(location_el) + + # Add the message to the updated list. + updated_message_els.append(message_el) + + @classmethod + def _find_message(cls, message, message_els): + """ Return the message element for a message from a list. """ + + for message_el in message_els: + source = '' + comment = '' + extra_comment = '' + extras = [] + + # Extract the data from the element. + for el in message_el: + if el.tag == 'source': + source = el.text + elif el.tag == 'comment': + comment = el.text + elif el.tag == 'extracomment': + extra_comment = el.text + elif el.tag.startswith('extra-'): + extras.append([el.tag[6:], el.text]) + + # Compare with the message. + if source != message.source: + continue + + if comment != message.comment: + continue + + if extra_comment != cls._get_message_extra_comments(message): + continue + + if extras != message.embedded_comments.extras: + continue + + return message_el + + return None + + @staticmethod + def _get_message_extra_comments(message): + """ Return a message's extra comments as they appear in a .ts file. """ + + return ' '.join(message.embedded_comments.extra_comments) + + def _get_updated_message_els(self, name): + """ Return the list of updated message elements for a context. """ + + try: + updated_message_els = self._updated_contexts[name] + except KeyError: + updated_message_els = [] + self._updated_contexts[name] = updated_message_els + + return updated_message_els + + def _make_location_el(self, message): + """ Return a 'location' element. """ + + return ElementTree.Element( + 'location', + filename=os.path.relpath( + message.filename, start=os.path.dirname(os.path.abspath(self._ts_file)) + ), + line=str(message.line_nr) + ) + + def _make_message_el(self, message): + """ Return a 'message' element. """ + + attrs = {} + + if message.embedded_comments.message_id: + attrs['id'] = message.embedded_comments.message_id + + if message.numerus: + attrs['numerus'] = 'yes' + + message_el = ElementTree.Element('message', attrs) + + source_el = ElementTree.Element('source') + source_el.text = message.source + message_el.append(source_el) + + if message.comment: + comment_el = ElementTree.Element('comment') + comment_el.text = message.comment + message_el.append(comment_el) + + if message.embedded_comments.extra_comments: + extracomment_el = ElementTree.Element('extracomment') + extracomment_el.text = self._get_message_extra_comments(message) + message_el.append(extracomment_el) + + translation_el = ElementTree.Element('translation', type='unfinished') + + # Try and find another message with the same source and use its + # translation if it has one. + translation = self._translations.get(message.source) + if translation: + translation_el.text = translation + + self.progress("Reused existing translation for '{0}'".format( + self.pretty(message.source)) + ) + self._nr_new_using_existing_translation += 1 + + if message.numerus: + translation_el.append(ElementTree.Element('numerusform')) + + message_el.append(translation_el) + + for field, value in message.embedded_comments.extras: + el = ElementTree.Element('extra-' + field) + el.text = value + message_el.append(el) + + self._new_message_els.append(message_el) + + return message_el + + def _summary(self): + """ Display the summary of changes to the user. """ + + summary_lines = [] + + # Display a line of the summary and the heading if not already done. + def summary(line): + nonlocal summary_lines + + if not summary_lines: + summary_lines.append("Summary of changes to {ts}:".format(ts=self._ts_file)) + + summary_lines.append(" " + line) + + if self._nr_new: + if self._nr_new_duplicates: + summary("{0} new messages were added (and {1} duplicates)".format( + self._nr_new, self._nr_new_duplicates)) + else: + summary("{0} new messages were added".format(self._nr_new)) + + if self._nr_new_using_existing_translation: + summary("{0} messages reused existing translations".format( + self._nr_new_using_existing_translation)) + + if self._nr_existing: + summary("{0} existing messages were found".format( + self._nr_existing)) + + if self._nr_kept_obsolete: + summary("{0} obsolete messages were kept".format( + self._nr_kept_obsolete)) + + if self._nr_discarded_obsolete: + summary("{0} obsolete messages were discarded".format( + self._nr_discarded_obsolete)) + + if self._nr_discarded_untranslated: + summary("{0} untranslated messages were discarded".format( + self._nr_discarded_untranslated)) + + if not summary_lines: + summary_lines.append("{ts} was unchanged".format(ts=self._ts_file)) + + print(os.linesep.join(summary_lines)) + + +# The XML of an empty .ts file. This is what a current lupdate will create +# with an empty C++ source file. +_EMPTY_TS = ''' + +''' diff --git a/i18n/pylupdate6/translations.py b/i18n/pylupdate6/translations.py new file mode 100644 index 00000000..fa9bdf81 --- /dev/null +++ b/i18n/pylupdate6/translations.py @@ -0,0 +1,51 @@ +# Copyright (c) 2021 Riverbank Computing Limited +# +# This file is part of PyQt6. +# +# This file may be used under the terms of the GNU General Public License +# version 3.0 as published by the Free Software Foundation and appearing in +# the file LICENSE included in the packaging of this file. Please review the +# following information to ensure the GNU General Public License version 3.0 +# requirements will be met: http://www.gnu.org/copyleft/gpl.html. +# +# If you do not wish to use this file under the terms of the GPL version 3.0 +# then you may purchase a commercial license. For more information contact +# info@riverbankcomputing.com. +# +# This file is provided AS IS with NO WARRANTY OF ANY KIND, INCLUDING THE +# WARRANTY OF DESIGN, MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. + + +class Context: + """ Encapsulate a message context. """ + + def __init__(self, name): + """ Initialise the context. """ + + self.name = name + self.messages = [] + + +class EmbeddedComments: + """ Encapsulate information for a translator embedded in comments. """ + + def __init__(self): + """ Initialise the object. """ + + self.message_id = '' + self.extra_comments = [] + self.extras = [] + + +class Message: + """ Encapsulate a message. """ + + def __init__(self, filename, line_nr, source, comment, numerus): + """ Initialise the message. """ + + self.filename = filename + self.line_nr = line_nr + self.source = source + self.comment = comment + self.numerus = numerus + self.embedded_comments = EmbeddedComments() diff --git a/i18n/pylupdate6/user.py b/i18n/pylupdate6/user.py new file mode 100644 index 00000000..2ff01632 --- /dev/null +++ b/i18n/pylupdate6/user.py @@ -0,0 +1,47 @@ +# Copyright (c) 2021 Riverbank Computing Limited +# +# This file is part of PyQt6. +# +# This file may be used under the terms of the GNU General Public License +# version 3.0 as published by the Free Software Foundation and appearing in +# the file LICENSE included in the packaging of this file. Please review the +# following information to ensure the GNU General Public License version 3.0 +# requirements will be met: http://www.gnu.org/copyleft/gpl.html. +# +# If you do not wish to use this file under the terms of the GPL version 3.0 +# then you may purchase a commercial license. For more information contact +# info@riverbankcomputing.com. +# +# This file is provided AS IS with NO WARRANTY OF ANY KIND, INCLUDING THE +# WARRANTY OF DESIGN, MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. + + +class UserException(Exception): + """ Encapsulate an exception ultimate caused by the user. """ + + pass + + +class User: + """ A mixin that provides methods for communicating with the user. """ + + def __init__(self, verbose, **kwargs): + """ Initialise the object. """ + + super().__init__(**kwargs) + + self._verbose = verbose + + @staticmethod + def pretty(text): + """ Returns a pretty-fied version of some text suitable for displaying + to the user. + """ + + return text.replace('\n', '\\n') + + def progress(self, message): + """ Display a progress message. """ + + if self._verbose: + print(message) diff --git a/setup.py b/setup.py index 8f29f133..42089ca6 100755 --- a/setup.py +++ b/setup.py @@ -379,13 +379,19 @@ def buildQtI18nTS(sysArgs): print("Updating Language Files:") print("") - try: - subprocess.call(["pylupdate5", "-verbose", "-noobsolete", *srcList, "-ts", *tsList]) - except Exception as e: - print("PyQt5 Linguist tools seem to be missing") - print("On Debian/Ubuntu, install: qttools5-dev-tools pyqt5-dev-tools") - print(str(e)) - sys.exit(1) + # Using the pylupdate tool from PyQt6 instead as it supports TS file + # format 2.1. This can perhaps be changed back to the installed tool + # at a later time. + from i18n.pylupdate6 import lupdate + lupdate(srcList, tsList, no_obsolete=True, no_summary=False) + + # try: + # subprocess.call(["pylupdate5", "-verbose", "-noobsolete", *srcList, "-ts", *tsList]) + # except Exception as e: + # print("PyQt5 Linguist tools seem to be missing") + # print("On Debian/Ubuntu, install: qttools5-dev-tools pyqt5-dev-tools") + # print(str(e)) + # sys.exit(1) print("") diff --git a/setup/debian/copyright b/setup/debian/copyright index a60ea0d4..f5986bc1 100644 --- a/setup/debian/copyright +++ b/setup/debian/copyright @@ -19,6 +19,22 @@ License: GPL-3.0-or-later You should have received a copy of the GNU General Public License along with this program. If not, see . +Files: i18n/pylupdate6/*.py +Copyright: 2021, Riverbank Computing Limited +License: GPL-3.0 + This file may be used under the terms of the GNU General Public License + version 3.0 as published by the Free Software Foundation and appearing in + the file LICENSE included in the packaging of this file. Please review the + following information to ensure the GNU General Public License version 3.0 + requirements will be met: http://www.gnu.org/copyleft/gpl.html. + . + If you do not wish to use this file under the terms of the GPL version 3.0 + then you may purchase a commercial license. For more information contact + info@riverbankcomputing.com. + . + This file is provided AS IS with NO WARRANTY OF ANY KIND, INCLUDING THE + WARRANTY OF DESIGN, MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. + Files: novelwriter/assets/icons/typicons_*.svg Copyright: 2019, Stephen Hutchings License: CC-BY-SA-4.0