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
This commit is contained in:
committed by
GitHub
parent
508d92c3e8
commit
f562937e81
@@ -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
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# Copyright (c) 2021 Riverbank Computing Limited <info@riverbankcomputing.com>
|
||||||
|
#
|
||||||
|
# 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"]
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
# Copyright (c) 2021 Riverbank Computing Limited <info@riverbankcomputing.com>
|
||||||
|
#
|
||||||
|
# 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()
|
||||||
@@ -0,0 +1,362 @@
|
|||||||
|
# Copyright (c) 2021 Riverbank Computing Limited <info@riverbankcomputing.com>
|
||||||
|
#
|
||||||
|
# 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
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# Copyright (c) 2021 Riverbank Computing Limited <info@riverbankcomputing.com>
|
||||||
|
#
|
||||||
|
# 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 = []
|
||||||
@@ -0,0 +1,390 @@
|
|||||||
|
# Copyright (c) 2021 Riverbank Computing Limited <info@riverbankcomputing.com>
|
||||||
|
#
|
||||||
|
# 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('<?xml version="1.0" encoding="utf-8"?>\n')
|
||||||
|
f.write('<!DOCTYPE TS>\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 = '''<TS version="2.1">
|
||||||
|
</TS>
|
||||||
|
'''
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
# Copyright (c) 2021 Riverbank Computing Limited <info@riverbankcomputing.com>
|
||||||
|
#
|
||||||
|
# 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()
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# Copyright (c) 2021 Riverbank Computing Limited <info@riverbankcomputing.com>
|
||||||
|
#
|
||||||
|
# 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)
|
||||||
@@ -379,13 +379,19 @@ def buildQtI18nTS(sysArgs):
|
|||||||
print("Updating Language Files:")
|
print("Updating Language Files:")
|
||||||
print("")
|
print("")
|
||||||
|
|
||||||
try:
|
# Using the pylupdate tool from PyQt6 instead as it supports TS file
|
||||||
subprocess.call(["pylupdate5", "-verbose", "-noobsolete", *srcList, "-ts", *tsList])
|
# format 2.1. This can perhaps be changed back to the installed tool
|
||||||
except Exception as e:
|
# at a later time.
|
||||||
print("PyQt5 Linguist tools seem to be missing")
|
from i18n.pylupdate6 import lupdate
|
||||||
print("On Debian/Ubuntu, install: qttools5-dev-tools pyqt5-dev-tools")
|
lupdate(srcList, tsList, no_obsolete=True, no_summary=False)
|
||||||
print(str(e))
|
|
||||||
sys.exit(1)
|
# 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("")
|
print("")
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,22 @@ License: GPL-3.0-or-later
|
|||||||
You should have received a copy of the GNU General Public License
|
You should have received a copy of the GNU General Public License
|
||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
Files: i18n/pylupdate6/*.py
|
||||||
|
Copyright: 2021, Riverbank Computing Limited <info@riverbankcomputing.com>
|
||||||
|
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
|
Files: novelwriter/assets/icons/typicons_*.svg
|
||||||
Copyright: 2019, Stephen Hutchings
|
Copyright: 2019, Stephen Hutchings
|
||||||
License: CC-BY-SA-4.0
|
License: CC-BY-SA-4.0
|
||||||
|
|||||||
Reference in New Issue
Block a user