diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e5dbfc8b..27a961e4 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -42,7 +42,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.10"] + python-version: ["3.11"] linux-tag: ["manylinux_2_24_x86_64", "manylinux_2_28_x86_64"] steps: - name: Python Setup @@ -57,13 +57,6 @@ jobs: - name: Checkout Source uses: actions/checkout@v3 - - name: Get Version - shell: bash - run: | - NW_VER=$(python setup.py version) - echo "novelWriter Version: $NW_VER" - echo "VERSION=$NW_VER" >> $GITHUB_ENV - - name: Download Artifacts uses: actions/download-artifact@v3 with: @@ -76,27 +69,20 @@ jobs: - name: Upload Artifacts uses: actions/upload-artifact@v3 with: - name: novelWriter-${{ env.VERSION }}-py${{ matrix.python-version }}-${{ matrix.linux-tag }}.AppImage - path: dist_appimage/novelWriter-${{ env.VERSION }}-py${{ matrix.python-version }}-${{ matrix.linux-tag }}.AppImage + name: novelWriter-Packages + path: | + dist_appimage/novelWriter*.AppImage + dist_appimage/novelWriter*.AppImage.sha256 if-no-files-found: error retention-days: 1 buildMac: needs: buildAssets - strategy: - fail-fast: false runs-on: macos-12 steps: - name: Checkout uses: actions/checkout@v3 - - name: Get Version - shell: bash - run: | - NW_VER=$(python3 setup.py version) - echo "novelWriter Version: $NW_VER" - echo "VERSION=$NW_VER" >> $GITHUB_ENV - - name: Download Artifacts uses: actions/download-artifact@v3 with: @@ -106,17 +92,12 @@ jobs: - name: Build App Bundle run: ./setup/macos/build.sh - - name: Upload App Zip - uses: actions/upload-artifact@v3 - with: - name: novelWriter-${{ env.VERSION }}-macos.app.zip - path: dist_macos/novelWriter-${{ env.VERSION }}-macos.app.zip - retention-days: 1 - - name: Upload DMG uses: actions/upload-artifact@v3 with: - name: novelWriter-${{ env.VERSION }}-macos.dmg - path: dist_macos/novelWriter-${{ env.VERSION }}-macos.dmg + name: novelWriter-Packages + path: | + dist_macos/novelWriter*.dmg + dist_macos/novelWriter*.dmg.sha256 if-no-files-found: error retention-days: 1 diff --git a/i18n/pylupdate6/README.md b/i18n/pylupdate6/README.md deleted file mode 100644 index 32ffae95..00000000 --- a/i18n/pylupdate6/README.md +++ /dev/null @@ -1,7 +0,0 @@ -# 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 deleted file mode 100644 index 698832fc..00000000 --- a/i18n/pylupdate6/__init__.py +++ /dev/null @@ -1,22 +0,0 @@ -# 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 deleted file mode 100644 index c624256a..00000000 --- a/i18n/pylupdate6/lupdate.py +++ /dev/null @@ -1,90 +0,0 @@ -# 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 deleted file mode 100644 index 6e01895c..00000000 --- a/i18n/pylupdate6/python_source.py +++ /dev/null @@ -1,362 +0,0 @@ -# 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 deleted file mode 100644 index 3cbbea29..00000000 --- a/i18n/pylupdate6/source_file.py +++ /dev/null @@ -1,29 +0,0 @@ -# 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 deleted file mode 100644 index ba71513c..00000000 --- a/i18n/pylupdate6/translation_file.py +++ /dev/null @@ -1,390 +0,0 @@ -# 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 deleted file mode 100644 index fa9bdf81..00000000 --- a/i18n/pylupdate6/translations.py +++ /dev/null @@ -1,51 +0,0 @@ -# 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 deleted file mode 100644 index 2ff01632..00000000 --- a/i18n/pylupdate6/user.py +++ /dev/null @@ -1,47 +0,0 @@ -# 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 2734af6f..b9baf406 100755 --- a/setup.py +++ b/setup.py @@ -73,12 +73,10 @@ def extractVersion(beQuiet=False): return numVers, hexVers, relDate -def compactVersion(numVers): - """Make the version number more compact.""" - numVers = numVers.replace("-alpha", "a") - numVers = numVers.replace("-beta", "b") - numVers = numVers.replace("-rc", "rc") - return numVers +def compactVersion(version): + """Make the version number more compact. + """ + return version.replace("-alpha", "a").replace("-beta", "b").replace("-rc", "rc") def sysCall(callArgs, cwd=None): @@ -95,20 +93,21 @@ def sysCall(callArgs, cwd=None): def readFile(fileName): """Read an entire file and return as a string. """ - with open(fileName, mode="r") as inFile: + with open(fileName, mode="r", encoding="utf-8") as inFile: return inFile.read() def writeFile(fileName, writeText): """Write string to file. """ - with open(fileName, mode="w+") as outFile: + with open(fileName, mode="w+", encoding="utf-8") as outFile: outFile.write(writeText) def toUpload(srcPath, dstName=None): """Copy a file produced by one of the build functions to the uplaod - directory. The file can optionally be given a new name.""" + directory. The file can optionally be given a new name. + """ uplDir = "dist_upload" if not os.path.isdir(uplDir): os.mkdir(uplDir) @@ -127,7 +126,7 @@ def makeCheckSum(sumFile, cwd=None): else: shaFile = os.path.join(cwd, sumFile+".sha256") with open(shaFile, mode="w") as fOut: - subprocess.call(["sha256sum", sumFile], stdout=fOut, cwd=cwd) + subprocess.call(["shasum", "-a", "256", sumFile], stdout=fOut, cwd=cwd) print("SHA256 Sum: %s" % shaFile) except Exception as exc: print("Could not generate sha256 file") @@ -331,6 +330,12 @@ def buildQtI18nTS(sysArgs): print("Building Qt Translation Files") print("=============================") + try: + from PyQt6.lupdate import lupdate + except ImportError: + print("ERROR: This command requires lupdate from PyQt6") + sys.exit(1) + print("") print("Scanning Source Tree:") print("") @@ -386,10 +391,7 @@ def buildQtI18nTS(sysArgs): print("Updating Language Files:") print("") - # 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 + # Using the pylupdate tool from PyQt6 as it supports TS file format 2.1. lupdate(srcList, tsList, no_obsolete=True, no_summary=False) print("") @@ -402,7 +404,7 @@ def buildQtI18nTS(sysArgs): ## def genMacOSPlist(): - """Set necessary values for .plist file. + """Set necessary values for .plist file for MacOS build. """ numVers, _, _ = extractVersion() pkgVers = compactVersion(numVers) @@ -936,11 +938,10 @@ def makeForLaunchpad(doSign=False, isFirst=False, isSnapshot=False): ## def makeAppImage(sysArgs): - """Build an Appimage. + """Build an AppImage. """ import glob import argparse - import platform try: import python_appimage # noqa F401 @@ -964,15 +965,15 @@ def makeAppImage(sysArgs): parser.add_argument( "--linux-tag", nargs="?", - default=f"manylinux2010_{platform.machine()}", + default="manylinux_2_28_x86_64", help=( - "linux compatibility tag (e.g. manylinux1_x86_64) \n" + "Linux compatibility tag (e.g. manylinux_2_28_x86_64)\n" "see https://python-appimage.readthedocs.io/en/latest/#available-python-appimages \n" "and https://github.com/pypa/manylinux for a list of valid tags" ), ) parser.add_argument( - "--python-version", nargs="?", default="3.10", help="python version (e.g. 3.10)" + "--python-version", nargs="?", default="3.11", help="Python version (e.g. 3.11)" ) args, unparsedArgs = parser.parse_known_args(sysArgs) @@ -1016,9 +1017,8 @@ def makeAppImage(sysArgs): os.mkdir(imageDir) - # Remove old Appimages + # Remove old AppImages outFiles = glob.glob(f"{bldDir}/*.AppImage") - if outFiles: print("Removing old AppImages") print("") @@ -1108,17 +1108,17 @@ def makeAppImage(sysArgs): print("Wrote: requirements.txt") shutil.copyfile("setup/data/novelwriter.desktop", f"{imageDir}/novelwriter.desktop") - print("Copied: setup/data/novelwriter.desktop") + print("Copied: novelwriter.desktop") shutil.copyfile("setup/icons/novelwriter.svg", f"{imageDir}/novelwriter.svg") - print("Copied: setup/icons/novelwriter.svg") + print("Copied: novelwriter.svg") shutil.copyfile( "setup/data/hicolor/256x256/apps/novelwriter.png", f"{imageDir}/novelwriter.png" ) - print("Copied: setup/data/hicolor/256x256/apps/novelwriter.png") + print("Copied: novelwriter.png") - # Build Appimage + # Build AppImage # ============== try: @@ -1131,13 +1131,17 @@ def makeAppImage(sysArgs): print("") print(str(exc)) print("") - print("Dependencies:") - print(" * pip install python-appimage") - print("") sys.exit(1) + linuxLabel = "" + if not linuxTag.startswith("manylinux_2_28"): + linuxLabel = "-oldlinux" + if not linuxTag.endswith("x86_64"): + # manylinux_2_28 only comes in 64 bit, so this one only applies to older images + linuxLabel += "-32bit" + bldFile = glob.glob(f"{bldDir}/*.AppImage")[0] - outFile = f"{bldDir}/novelWriter-{pkgVers}-py{pythonVer}-{linuxTag}.AppImage" + outFile = f"{bldDir}/novelWriter-{pkgVers}{linuxLabel}.AppImage" os.rename(bldFile, outFile) shaFile = makeCheckSum(os.path.basename(outFile), cwd=bldDir) @@ -1368,11 +1372,8 @@ def makeWindowsEmbedded(sysArgs): print(str(exc)) sys.exit(1) - issName = os.path.join("dist", f"novelwriter-{packVersion}-win10-amd64-setup.exe") - newName = os.path.join("dist", f"novelwriter-{packVersion}-py{pyVers}-win10-amd64-setup.exe") - os.replace(issName, newName) - - print(f"Installer: {newName}") + print("") + print("Done") print("") return @@ -1904,7 +1905,7 @@ if __name__ == "__main__": " build-win-exe Build a setup.exe file with Python embedded for Windows.", " The package must be built from a minimal windows zip file.", " build-appimage Build an AppImage. Argument --linux-tag defaults to", - " manylinux1_x86_64 / i386, and --python-version to 3.10.", + " manylinux_2_28_x86_64, and --python-version to 3.11.", "", "System Install:", "", diff --git a/setup/appimage_launcher.sh b/setup/appimage_launcher.sh new file mode 100755 index 00000000..fc0aa9e9 --- /dev/null +++ b/setup/appimage_launcher.sh @@ -0,0 +1,36 @@ +#!/bin/bash + +if [ -z "$1" ]; then + echo "Please provide the path to the novelWriter.AppImage file as an argument" + exit 1 +fi + +if [ ! -e "$1" ]; then + echo "File not found" + exit 1 +fi + +IMGPATH="$(readlink -m "$1")" +echo "AppImage: $IMGPATH" + +ICONPATH="$(dirname "$IMGPATH")/novelWriter.png" + +if [ ! -e "$ICONPATH" ]; then + echo "Downloading icon" + wget https://raw.githubusercontent.com/vkbo/novelWriter/main/setup/data/hicolor/256x256/apps/novelwriter.png -O "$ICONPATH" +fi + +cat > $HOME/.local/share/applications/novelWriter.desktop < Source: https://github.com/vkbo/novelWriter Files: * -Copyright: 2018-2021, Veronica Berglyd Olsen +Copyright: 2018-2023, Veronica Berglyd Olsen License: GPL-3.0-or-later This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by @@ -19,22 +19,6 @@ 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 diff --git a/setup/iss_license.txt b/setup/iss_license.txt index 63223cfb..1cab3899 100644 --- a/setup/iss_license.txt +++ b/setup/iss_license.txt @@ -1,6 +1,6 @@ novelWriter License -Copyright (C) 2018-2022 Veronica Berglyd Olsen +Copyright (C) 2018-2023 Veronica Berglyd Olsen License: GPL v3+ This program is free software: you can redistribute it and/or modify diff --git a/setup/macos/build.sh b/setup/macos/build.sh index af92b5f5..b9192b42 100755 --- a/setup/macos/build.sh +++ b/setup/macos/build.sh @@ -12,8 +12,6 @@ SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd ) SRC_DIR="$SCRIPT_DIR/../.." RLS_DIR="$SRC_DIR/dist_macos" -echo "Script Dir: $SCRIPT_DIR" - cleanup () { if [ -d "$BUILD_DIR" ]; then rm -rf "$BUILD_DIR" @@ -21,13 +19,14 @@ cleanup () { } trap cleanup EXIT -echo "Building in: $BUILD_DIR" - -OLD_CWD="$(pwd)" -VERSION="$(awk '/^__version__/{print substr($NF,2,length($NF)-2)}' $SRC_DIR/novelwriter/__init__.py)" +echo "Script Dir: $SCRIPT_DIR" +echo "Build Dir: $BUILD_DIR" pushd "$SRC_DIR" || exit 1 +VERSION="$(python3 setup.py version)" +echo "novelWriter Version: $VERSION" + # --- Prepare Files ----------------------------------------------------------------------------- # echo "Generating Info.plist" @@ -68,23 +67,21 @@ pushd "$BUILD_DIR"/ || exit 1 # --- Create Miniconda Env ---------------------------------------------------------------------- # -# install Miniconda, a self-contained Python distribution echo "Downloading Miniconda ..." curl -LO https://repo.continuum.io/miniconda/Miniconda3-latest-MacOSX-x86_64.sh bash Miniconda3-latest-MacOSX-x86_64.sh -b -p ~/miniconda -f rm Miniconda3-latest-MacOSX-x86_64.sh export PATH="$HOME/miniconda/bin:$PATH" -echo "Creating conda env ..." -# create conda env +echo "Creating Conda env ..." conda create -n novelWriter -c conda-forge python=3.10 --yes source activate novelWriter -echo "installing dictionaries ..." +echo "Installing dictionaries ..." conda install -c conda-forge enchant hunspell-en --yes # Install dependencies -echo "installing python dependencies ..." +echo "Installing Python dependencies ..." pip install -r "$SRC_DIR/requirements.txt" # Leave conda env @@ -92,8 +89,9 @@ conda deactivate # --- Build App --------------------------------------------------------------------------------- # -echo "Building app bundle ..." -# create .app Framework +echo "Building App bundle ..." + +# Create .app Framework mkdir -p novelWriter.app/Contents/ mkdir novelWriter.app/Contents/MacOS novelWriter.app/Contents/Resources novelWriter.app/Contents/Resources/novelWriter cp $SRC_DIR/setup/macos/Info.plist novelWriter.app/Contents/Info.plist @@ -130,13 +128,11 @@ chmod a+x novelWriter.app/Contents/MacOS/novelWriter # echo "Signing bundle ..." # sudo codesign --sign - --deep --force --entitlements "$SCRIPT_DIR/../macos/App.entitlements" --options runtime "novelWriter.app/Contents/MacOS/novelWriter" -# Remove bloat -pushd novelWriter.app/Contents/Resources || exit 1 - # --- Cleanup ----------------------------------------------------------------------------------- # echo "Cleaning unused files from bundle ..." -# cleanup commands HERE + +pushd novelWriter.app/Contents/Resources || exit 1 find . -type d -iname '__pycache__' -print0 | xargs -0 rm -r rm -rf pkgs @@ -146,7 +142,7 @@ rm -rf share/{gtk-,}doc # Remove the files from the 3.1 symlink rm -rf lib/python3.1 -# remove web engine +# Remove web engine rm lib/python3.*/site-packages/PyQt5/QtWebEngine* || true rm -r lib/python3.*/site-packages/PyQt5/Qt/translations/qtwebengine* || true rm lib/python3.*/site-packages/PyQt5/Qt/resources/qtwebengine* || true @@ -158,25 +154,19 @@ rm lib/python3.*/site-packages/PyQt5/Qt/lib/libQt5WebEngine* || true popd || exit 1 popd || exit 1 -# --- Create App Bundle-------------------------------------------------------------------------- # - -echo "Packageing App ..." mkdir -p $RLS_DIR -pushd $BUILD_DIR || exit 1 -zip -qr novelWriter.app.zip novelWriter.app -popd || exit 1 - -mv -v $BUILD_DIR/novelWriter.app.zip $RLS_DIR/novelWriter-"${VERSION}"-macos.app.zip - # --- Create DMG -------------------------------------------------------------------------------- # # Generate .dmg echo "Packageing DMG ..." brew install create-dmg -# "--skip-jenkins" is a temporary workaround for https://github.com/create-dmg/create-dmg/issues/72 create-dmg --volname "novelWriter $VERSION" --volicon $SRC_DIR/setup/macos/novelwriter.icns \ --window-pos 200 120 --window-size 800 400 --icon-size 100 \ --icon novelWriter.app 200 190 --hide-extension novelWriter.app \ - --app-drop-link 600 185 $RLS_DIR/novelWriter-"${VERSION}"-macos.dmg "$BUILD_DIR"/ + --app-drop-link 600 185 $RLS_DIR/novelWriter-"${VERSION}".dmg "$BUILD_DIR"/ + +pushd $RLS_DIR || exit 1 +shasum -a 256 novelWriter-"${VERSION}".dmg | tee novelWriter-"${VERSION}".dmg.sha256 +popd || exit 1 diff --git a/setup/win_setup_embed.iss b/setup/win_setup_embed.iss index 7505f6c1..09337df1 100644 --- a/setup/win_setup_embed.iss +++ b/setup/win_setup_embed.iss @@ -24,7 +24,7 @@ DisableProgramGroupPage=yes UsedUserAreasWarning=no PrivilegesRequiredOverridesAllowed=dialog OutputDir={#nwAppDir} -OutputBaseFilename=novelwriter-{#nwAppVersion}-win10-amd64-setup +OutputBaseFilename=novelwriter-{#nwAppVersion}-amd64-setup Compression=lzma SolidCompression=yes WizardStyle=modern