From d6d41842ea9dbcb0d2aac15efdc1f273413b4cb7 Mon Sep 17 00:00:00 2001 From: Beka Gozalishvili Date: Mon, 31 Aug 2026 07:59:06 +0400 Subject: [PATCH 1/2] Migrate to nvda-addon-kit from addontemplate --- .github/workflows/release.yml | 49 ++- .gitignore | 9 +- .pre-commit-config.yaml | 7 - addon.toml | 58 +++ buildVars.py | 99 ----- manifest-translated.ini.tpl | 2 - manifest.ini.tpl | 10 - pyproject.toml | 28 +- requirements.txt | 2 - sconstruct | 397 ------------------ site_scons/site_tools/gettexttool/__init__.py | 55 --- uv.lock | 92 ++++ 12 files changed, 212 insertions(+), 596 deletions(-) delete mode 100644 .pre-commit-config.yaml create mode 100644 addon.toml delete mode 100644 buildVars.py delete mode 100644 manifest-translated.ini.tpl delete mode 100644 manifest.ini.tpl delete mode 100644 requirements.txt delete mode 100644 sconstruct delete mode 100644 site_scons/site_tools/gettexttool/__init__.py create mode 100644 uv.lock diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7e42ee6..0ee9921 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -2,8 +2,10 @@ name: Addon-release on: push: - tags: - - "v*" + branches: [master] + tags: ["v*"] + pull_request: + workflow_dispatch: jobs: release: @@ -14,23 +16,27 @@ jobs: - name: Checkout uses: actions/checkout@v4 - - name: Setup Python - uses: actions/setup-python@v5 + - name: Install uv + uses: astral-sh/setup-uv@v10 with: - python-version: 3.11 - cache: "pip" + enable-cache: true + python-version: "3.13" - name: Install dependencies - run: | - pip install scons markdown - sudo apt update - sudo apt install gettext + run: uv sync + + - name: Lint + run: uv run ruff check addon + + - name: Validate addon.toml + run: uv run nvaddon check - name: Build addon - run: scons + run: uv run nvaddon build - name: Upload release id: release + if: startsWith(github.ref, 'refs/tags/') uses: softprops/action-gh-release@v2 with: token: ${{ secrets.GITHUB_TOKEN }} @@ -38,19 +44,26 @@ jobs: files: | *.nvda-addon generate_release_notes: true - prerelease: false + prerelease: ${{ !endsWith(github.ref, '-stable') }} - name: extract addon info + if: startsWith(github.ref, 'refs/tags/') run: | - python3 -c 'from buildVars import addon_info - env = { - "ADDON_NAME": addon_info["addon_name"], - "ADDON_VERSION": addon_info["addon_version"], - "ADDON_CHANNEL": addon_info["addon_updateChannel"], + uv run python -c ' + import os, tomllib + addon = tomllib.load(open("addon.toml", "rb"))["addon"] + fields = { + "ADDON_NAME": addon["name"], + "ADDON_VERSION": addon["version"], + "ADDON_CHANNEL": addon.get("updateChannel") or "stable", } - [print(f"{k}={v}") for k, v in env.items()]' > "$GITHUB_ENV" + with open(os.environ["GITHUB_ENV"], "a") as env: + for key, value in fields.items(): + env.write(f"{key}={value}\n") + ' - name: submit addon + if: startsWith(github.ref, 'refs/tags/') uses: beqabeqa473/submitNVDAAddon@v1 with: addon_name: ${{ env.ADDON_NAME }} diff --git a/.gitignore b/.gitignore index 0be8af1..497bb03 100644 --- a/.gitignore +++ b/.gitignore @@ -7,5 +7,12 @@ manifest.ini *.pot *.py[co] *.nvda-addon -.sconsign.dblite /[0-9]*.[0-9]*.[0-9]*.json + +addon/manifest.ini +addon/locale/*/manifest.ini +addon/doc/*/*.html + +# uv creates this locally; never commit it. +.venv/ +__pycache__/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml deleted file mode 100644 index dd7a9d6..0000000 --- a/.pre-commit-config.yaml +++ /dev/null @@ -1,7 +0,0 @@ -repos: - - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.3.0 - hooks: - - id: check-ast - - id: check-case-conflict - - id: check-yaml diff --git a/addon.toml b/addon.toml new file mode 100644 index 0000000..5def8c0 --- /dev/null +++ b/addon.toml @@ -0,0 +1,58 @@ +# Configuration for this NVDA add-on, migrated from buildVars.py. +# +# The comment directly above a translatable string is that string's note to +# translators, and is carried into the generated .pot file. + +[addon] +# add-on Name/identifier, internal for NVDA +name = "instantTranslate" +# Add-on summary, usually the user visible name of the addon. +summary = "Instant Translate " + +# Add-on description +# Translators: Long description to be shown for this add-on on add-on information from add-ons manager +description = "This addon translates selected or clipboard text using Translate services and presents it to the user." + +# version +version = "5.1.0" +# Author(s) +author = "Alexy Sadovoy aka Lex , ruslan , beqa , Mesar Hameed , Alberto Buffolino , and other NVDA contributors" +# URL for the add-on documentation support +url = "https://github.com/addonFactory/instantTranslate" +# URL for the add-on repository where the source code can be found +sourceURL = "https://github.com/addonFactory/instantTranslate" +# Documentation file name +docFileName = "readme.html" +# Minimum NVDA version supported (e.g. "2018.3.0", minor version is optional) +minimumNVDAVersion = "2019.3.0" +# Last NVDA version supported/tested (e.g. "2018.4.0", ideally more recent than minimum version) +lastTestedNVDAVersion = "2026.1.0" +# Add-on update channel (default is None, denoting stable releases, +# and for development releases, use "dev".) +# Do not change unless you know what you are doing! +updateChannel = "stable" +# Add-on license such as GPL 2 +license = "GPL v2" +# URL for the license document the ad-on is licensed under +licenseURL = "https://www.gnu.org/licenses/gpl-2.0.html" + +[release] +tagPrefix = "v" + +[build] +# The Python files that make up this add-on, scanned for translatable strings. +pythonSources = ["addon/globalPlugins/instantTranslate/*.py", "addon/globalPlugins/instantTranslate/translators/*.py"] + +# Files inside the `addon` directory to leave out of the built add-on. +excludedFiles = [] + +[l10n] +# The language this add-on is written in. +baseLanguage = "en" + +# Extra files to scan for translatable strings. +extraSources = [] + +[docs] +# Python-Markdown extensions used when rendering documentation. +markdownExtensions = [] diff --git a/buildVars.py b/buildVars.py deleted file mode 100644 index e4759e3..0000000 --- a/buildVars.py +++ /dev/null @@ -1,99 +0,0 @@ -# -*- coding: UTF-8 -*- - -# Build customizations -# Change this file instead of sconstruct or manifest files, whenever possible. - - -# Since some strings in `addon_info` are translatable, -# we need to include them in the .po files. -# Gettext recognizes only strings given as parameters to the `_` function. -# To avoid initializing translations in this module we simply roll our own "fake" `_` function -# which returns whatever is given to it as an argument. -def _(arg): - return arg - - -# Add-on information variables -addon_info = { - # add-on Name/identifier, internal for NVDA - "addon_name": "instantTranslate", - # Add-on summary, usually the user visible name of the addon. - "addon_summary": "Instant Translate ", - # Add-on description - # Translators: Long description to be shown for this add-on on add-on information from add-ons manager - "addon_description": _("""This addon translates selected or clipboard text using Translate services and presents it to the user."""), - # version - "addon_version": "5.1.0", - # Author(s) - "addon_author": "Alexy Sadovoy aka Lex , ruslan , beqa , Mesar Hameed , Alberto Buffolino , and other NVDA contributors", - # URL for the add-on documentation support - "addon_url": "https://github.com/addonFactory/instantTranslate", - # URL for the add-on repository where the source code can be found - "addon_sourceURL": "https://github.com/addonFactory/instantTranslate", - # Documentation file name - "addon_docFileName": "readme.html", - # Minimum NVDA version supported (e.g. "2018.3.0", minor version is optional) - "addon_minimumNVDAVersion": "2019.3.0", - # Last NVDA version supported/tested (e.g. "2018.4.0", ideally more recent than minimum version) - "addon_lastTestedNVDAVersion": "2026.1.0", - # Add-on update channel (default is None, denoting stable releases, - # and for development releases, use "dev".) - # Do not change unless you know what you are doing! - "addon_updateChannel": "stable", - # Add-on license such as GPL 2 - "addon_license": "GPL v2", - # URL for the license document the ad-on is licensed under - "addon_licenseURL": "https://www.gnu.org/licenses/gpl-2.0.html", -} - -# Define the python files that are the sources of your add-on. -# You can either list every file (using ""/") as a path separator, -# or use glob expressions. -# For example to include all files with a ".py" extension from the "globalPlugins" dir of your add-on -# the list can be written as follows: -# pythonSources = ["addon/globalPlugins/*.py"] -# For more information on SCons Glob expressions please take a look at: -# https://scons.org/doc/production/HTML/scons-user/apd.html -pythonSources = [ - "addon/globalPlugins/instantTranslate/*.py", - "addon/globalPlugins/instantTranslate/translators/*.py", -] - -# Files that contain strings for translation. Usually your python sources -i18nSources = pythonSources + ["buildVars.py"] - -# Files that will be ignored when building the nvda-addon file -# Paths are relative to the addon directory, not to the root directory of your addon sources. -excludedFiles = [] - -# Base language for the NVDA add-on -# If your add-on is written in a language other than english, modify this variable. -# For example, set baseLanguage to "es" if your add-on is primarily written in spanish. -# You must also edit .gitignore file to specify base language files to be ignored. -baseLanguage = "en" - -# Markdown extensions for add-on documentation -# Most add-ons do not require additional Markdown extensions. -# If you need to add support for markup such as tables, fill out the below list. -# Extensions string must be of the form "markdown.extensions.extensionName" -# e.g. "markdown.extensions.tables" to add tables. -markdownExtensions = [] - -# Custom braille translation tables -# If your add-on includes custom braille tables (most will not), fill out this dictionary. -# Each key is a dictionary named according to braille table file name, -# with keys inside recording the following attributes: -# displayName (name of the table shown to users and translatable), -# contracted (contracted (True) or uncontracted (False) braille code), -# output (shown in output table list), -# input (shown in input table list). -brailleTables = {} - -# Custom speech symbol dictionaries -# Symbol dictionary files reside in the locale folder, e.g. `locale\en`, and are named `symbols-.dic`. -# If your add-on includes custom speech symbol dictionaries (most will not), fill out this dictionary. -# Each key is the name of the dictionary, -# with keys inside recording the following attributes: -# displayName (name of the speech dictionary shown to users and translatable), -# mandatory (True when always enabled, False when not. -symbolDictionaries = {} diff --git a/manifest-translated.ini.tpl b/manifest-translated.ini.tpl deleted file mode 100644 index c06aa84..0000000 --- a/manifest-translated.ini.tpl +++ /dev/null @@ -1,2 +0,0 @@ -summary = "{addon_summary}" -description = """{addon_description}""" diff --git a/manifest.ini.tpl b/manifest.ini.tpl deleted file mode 100644 index d44355d..0000000 --- a/manifest.ini.tpl +++ /dev/null @@ -1,10 +0,0 @@ -name = {addon_name} -summary = "{addon_summary}" -description = """{addon_description}""" -author = "{addon_author}" -url = {addon_url} -version = {addon_version} -docFileName = {addon_docFileName} -minimumNVDAVersion = {addon_minimumNVDAVersion} -lastTestedNVDAVersion = {addon_lastTestedNVDAVersion} -updateChannel = {addon_updateChannel} diff --git a/pyproject.toml b/pyproject.toml index 1c6ccbf..485ef43 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,3 +1,26 @@ +# Development environment for this add-on. +# +# The add-on itself is not a Python package -- NVDA loads it from the `addon` +# directory -- so nothing here is published. This file declares the tools needed +# to build and lint it. The add-on's own metadata lives in addon.toml. + +[project] +name = "instanttranslate-dev" +version = "0.0.0" +description = "Development environment for the Instant Translate NVDA add-on" +requires-python = ">=3.13" +dependencies = [ + "nvda-addon-kit", +] + +[dependency-groups] +lint = [ + "ruff==0.16.5", +] +dev = [ + { include-group = "lint" }, +] + [tool.ruff] builtins = [ "_", @@ -8,8 +31,3 @@ builtins = [ [tool.ruff.lint] select = ["E4", "E7", "E9", "F"] ignore = ["N802", "N803"] - -[tool.ruff.lint.per-file-ignores] -# sconstruct contains many inbuilt functions not recognised by the lint, -# so ignore F821. -"sconstruct" = ["F821"] diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 4e5e03b..0000000 --- a/requirements.txt +++ /dev/null @@ -1,2 +0,0 @@ -Markdown==3.7 -SCons==4.8.1 diff --git a/sconstruct b/sconstruct deleted file mode 100644 index 47805d6..0000000 --- a/sconstruct +++ /dev/null @@ -1,397 +0,0 @@ -# NVDA add-on template SCONSTRUCT file -# Copyright (C) 2012-2025 Rui Batista, Noelia Martinez, Joseph Lee -# This file is covered by the GNU General Public License. -# See the file COPYING.txt for more details. -import codecs -import gettext -import os -import os.path -import re -import subprocess -import sys -import zipfile - -# Add-on localization exchange facility and the template requires Python 3.10. -# For best practice, use Python 3.11 or later to align with NVDA development. -EnsurePythonVersion(3, 10) -sys.dont_write_bytecode = True - -# Bytecode should not be written for build vars module to keep the repository root folder clean. -import buildVars # NOQA: E402 - -VERSION_REGEX = re.compile(r'(?<="addon_version"\: ")(\d+\.\d+\.\d+)(?=")') -CHANNEL_REGEX = re.compile( - r'(?<="addon_updateChannel"\: )(.*?)(?=,$)', flags=re.MULTILINE -) - - -def getCurrentGitBranch(): - result = subprocess.run( - ["git", "rev-parse", "--abbrev-ref", "HEAD"], - check=True, - text=True, - capture_output=True, - ) - return result.stdout.strip() - - -def md2html(source, dest, env): - import markdown - - # Use extensions if defined. - mdExtensions = buildVars.markdownExtensions - lang = os.path.basename(os.path.dirname(source)).replace("_", "-") - localeLang = os.path.basename(os.path.dirname(source)) - try: - _ = gettext.translation( - "nvda", localedir=os.path.join("addon", "locale"), languages=[localeLang] - ).gettext - summary = _(env["addon_summary"]) - except Exception: - summary = env["addon_summary"] - title = "{addonSummary} {addonVersion}".format( - addonSummary=summary, addonVersion=env["version"] - ) - headerDic = { - '[[!meta title="': "# ", - '"]]': " #", - } - with codecs.open(source, "r", "utf-8") as f: - mdText = f.read() - for k, v in headerDic.items(): - mdText = mdText.replace(k, v, 1) - htmlText = markdown.markdown(mdText, extensions=mdExtensions) - # Optimization: build resulting HTML text in one go instead of writing parts separately. - docText = "\n".join( - [ - "", - f'', - "", - '', - '', - '', - f"{title}", - "\n", - htmlText, - "\n", - ] - ) - with codecs.open(dest, "w", "utf-8") as f: - f.write(docText) - - -def mdTool(env): - mdAction = env.Action( - lambda target, source, env: md2html(source[0].path, target[0].path, env), - lambda target, source, env: f"Generating {target[0]}", - ) - mdBuilder = env.Builder( - action=mdAction, - suffix=".html", - src_suffix=".md", - ) - env["BUILDERS"]["markdown"] = mdBuilder - - -def validateVersionNumber(key, val, env): - # Used to make sure version major.minor.patch are integers to comply with NV Access add-on store. - # Ignore all this if version number is not specified. - if val == "0.0.0": - return - versionNumber = val.split(".") - if len(versionNumber) < 3: - raise ValueError("versionNumber must have three parts (major.minor.patch)") - if not all([part.isnumeric() for part in versionNumber]): - raise ValueError("versionNumber (major.minor.patch) must be integers") - - -def validateChannel(key, val, env): - if val not in ["stable", "beta", "dev"]: - raise ValueError("Invalid channel. Must be stable, beta or dev") - - -vars = Variables() -vars.Add("version", "The version of this build", buildVars.addon_info["addon_version"]) -vars.Add( - "versionNumber", - "Version number of the form major.minor.patch", - "0.0.0", - validateVersionNumber, -) -vars.Add(BoolVariable("dev", "Whether this is a daily development version", False)) -vars.Add( - "channel", - "Update channel for this build", - buildVars.addon_info["addon_updateChannel"], -) - -env = Environment(variables=vars, ENV=os.environ, tools=["gettexttool", mdTool]) -env.Append(**buildVars.addon_info) - -if env["dev"]: - import datetime - - buildDate = datetime.datetime.now() - year, month, day = str(buildDate.year), str(buildDate.month), str(buildDate.day) - versionTimestamp = "".join([year, month.zfill(2), day.zfill(2)]) - env["addon_version"] = f"{versionTimestamp}.0.0" - env["versionNumber"] = f"{versionTimestamp}.0.0" - env["channel"] = "dev" -elif env["version"] is not None: - env["addon_version"] = env["version"] -if "channel" in env and env["channel"] is not None: - env["addon_updateChannel"] = env["channel"] - -addonFile = env.File("${addon_name}-${addon_version}.nvda-addon") - - -def addonGenerator(target, source, env, for_signature): - action = env.Action( - lambda target, source, env: createAddonBundleFromPath( - source[0].abspath, target[0].abspath - ) - and None, - lambda target, source, env: f"Generating Addon {target[0]}", - ) - return action - - -def manifestGenerator(target, source, env, for_signature): - action = env.Action( - lambda target, source, env: generateManifest( - source[0].abspath, target[0].abspath - ) - and None, - lambda target, source, env: f"Generating manifest {target[0]}", - ) - return action - - -def translatedManifestGenerator(target, source, env, for_signature): - dir = os.path.abspath(os.path.join(os.path.dirname(str(source[0])), "..")) - lang = os.path.basename(dir) - action = env.Action( - lambda target, source, env: generateTranslatedManifest( - source[1].abspath, lang, target[0].abspath - ) - and None, - lambda target, source, env: f"Generating translated manifest {target[0]}", - ) - return action - - -def releaseGenerator(target, source, env, for_signature): - action = env.Action( - lambda target, source, env: createRelease(target, source, env), - lambda target, source, env: "Creating release", - ) - return action - - -env["BUILDERS"]["NVDAAddon"] = Builder(generator=addonGenerator) -env["BUILDERS"]["NVDAManifest"] = Builder(generator=manifestGenerator) -env["BUILDERS"]["NVDATranslatedManifest"] = Builder( - generator=translatedManifestGenerator -) -env["BUILDERS"]["release"] = Builder(generator=releaseGenerator) - - -def createAddonHelp(dir): - docsDir = os.path.join(dir, "doc") - if os.path.isfile("style.css"): - cssPath = os.path.join(docsDir, "style.css") - cssTarget = env.Command(cssPath, "style.css", Copy("$TARGET", "$SOURCE")) - env.Depends(addon, cssTarget) - if os.path.isfile("readme.md"): - readmePath = os.path.join(docsDir, buildVars.baseLanguage, "readme.md") - readmeTarget = env.Command(readmePath, "readme.md", Copy("$TARGET", "$SOURCE")) - env.Depends(addon, readmeTarget) - - -def createAddonBundleFromPath(path, dest): - """Creates a bundle from a directory that contains an addon manifest file.""" - basedir = os.path.abspath(path) - with zipfile.ZipFile(dest, "w", zipfile.ZIP_DEFLATED) as z: - # FIXME: the include/exclude feature may or may not be useful. Also python files can be pre-compiled. - for dir, dirnames, filenames in os.walk(basedir): - relativePath = os.path.relpath(dir, basedir) - for filename in filenames: - pathInBundle = os.path.join(relativePath, filename) - absPath = os.path.join(dir, filename) - if pathInBundle not in buildVars.excludedFiles: - z.write(absPath, pathInBundle) - return dest - - -def generateManifest(source, dest): - # Prepare the root manifest section - addon_info = buildVars.addon_info - with codecs.open(source, "r", "utf-8") as f: - manifest_template = f.read() - manifest = manifest_template.format(**addon_info) - # Add additional manifest sections such as custom braile tables - # Custom braille translation tables - if getattr(buildVars, "brailleTables", {}): - manifest_brailleTables = ["\n[brailleTables]"] - for table in buildVars.brailleTables.keys(): - manifest_brailleTables.append(f"[[{table}]]") - for key, val in buildVars.brailleTables[table].items(): - manifest_brailleTables.append(f"{key} = {val}") - manifest += "\n".join(manifest_brailleTables) + "\n" - - # Custom speech symbol dictionaries - if getattr(buildVars, "symbolDictionaries", {}): - manifest_symbolDictionaries = ["\n[symbolDictionaries]"] - for dictionary in buildVars.symbolDictionaries.keys(): - manifest_symbolDictionaries.append(f"[[{dictionary}]]") - for key, val in buildVars.symbolDictionaries[dictionary].items(): - manifest_symbolDictionaries.append(f"{key} = {val}") - manifest += "\n".join(manifest_symbolDictionaries) + "\n" - - with codecs.open(dest, "w", "utf-8") as f: - f.write(manifest) - - -def generateTranslatedManifest(source, language, out): - _ = gettext.translation( - "nvda", localedir=os.path.join("addon", "locale"), languages=[language] - ).gettext - vars = {} - for var in ("addon_summary", "addon_description"): - vars[var] = _(buildVars.addon_info[var]) - with codecs.open(source, "r", "utf-8") as f: - manifest_template = f.read() - result = manifest_template.format(**vars) - # Add additional manifest sections such as custom braile tables - # Custom braille translation tables - if getattr(buildVars, "brailleTables", {}): - result_brailleTables = ["\n[brailleTables]"] - for table in buildVars.brailleTables.keys(): - result_brailleTables.append(f"[[{table}]]") - # Fetch display name only. - result_brailleTables.append( - f"displayName = {_(buildVars.brailleTables[table]['displayName'])}" - ) - result += "\n".join(result_brailleTables) + "\n" - - # Custom speech symbol dictionaries - if getattr(buildVars, "symbolDictionaries", {}): - result_symbolDictionaries = ["\n[symbolDictionaries]"] - for dictionary in buildVars.symbolDictionaries.keys(): - result_symbolDictionaries.append(f"[[{dictionary}]]") - # Fetch display name only. - result_symbolDictionaries.append( - f"displayName = {_(buildVars.symbolDictionaries[dictionary]['displayName'])}" - ) - result += "\n".join(result_symbolDictionaries) + "\n" - - with codecs.open(out, "w", "utf-8") as f: - f.write(result) - - -def createRelease(target, source, env): - version = ARGUMENTS.get("version") - channel = ARGUMENTS.get("channel") - if not version: - raise ValueError("version not specified") - if not channel: - raise ValueError("Channel not specified") - validateVersionNumber(None, version, env) - validateChannel(None, channel, env) - validateVersionNumber(None, buildVars.addon_info["addon_version"], env) - newReleaseVersionComponents = [int(i) for i in env["version"].split(".")] - currentVersionComponents = [ - int(i) for i in buildVars.addon_info["addon_version"].split(".") - ] - if newReleaseVersionComponents <= currentVersionComponents: - raise ValueError( - f"version {env['version']} is lower than or equal to version {buildVars.addon_info['addon_version']}" - ) - branch_name = getCurrentGitBranch() - with open("buildVars.py", "rb") as f: - buildVarsContents = f.read().decode() - buildVarsContents = VERSION_REGEX.sub(version, buildVarsContents) - buildVarsContents = CHANNEL_REGEX.sub(f'"{channel}"', buildVarsContents) - with open("buildVars.py", "wb") as f: - f.write(buildVarsContents.encode()) - env.Execute("git add buildVars.py") - env.Execute(f'git commit -m "Release version {version}-{channel}"') - tag = f"v{version}-{channel}" - env.Execute(f"git tag {tag}") - print(f'Previous version: {buildVars.addon_info["addon_version"]}') - input(f"Press enter to push the release {version}-{channel}") - env.Execute(f"git push --atomic origin {branch_name} {tag}") - - -def expandGlobs(files): - return [f for pattern in files for f in env.Glob(pattern)] - - -addon = env.NVDAAddon(addonFile, env.Dir("addon")) -release = env.release(".release", []) - -langDirs = [f for f in env.Glob(os.path.join("addon", "locale", "*"))] - -# Allow all NVDA's gettext po files to be compiled in source/locale, and manifest files to be generated -moByLang = {} -for dir in langDirs: - poFile = dir.File(os.path.join("LC_MESSAGES", "nvda.po")) - moFile = env.gettextMoFile(poFile) - moByLang[dir] = moFile - env.Depends(moFile, poFile) - translatedManifest = env.NVDATranslatedManifest( - dir.File("manifest.ini"), [moFile, os.path.join("manifest-translated.ini.tpl")] - ) - env.Depends(translatedManifest, ["buildVars.py"]) - env.Depends(addon, [translatedManifest, moFile]) - -pythonFiles = expandGlobs(buildVars.pythonSources) -for file in pythonFiles: - env.Depends(addon, file) - -# Convert markdown files to html -# We need at least doc in English and should enable the Help button for the add-on in Add-ons Manager -createAddonHelp("addon") -for mdFile in env.Glob(os.path.join("addon", "doc", "*", "*.md")): - # the title of the html file is translated based on the contents of something in the moFile for a language. - # Thus, we find the moFile for this language and depend on it if it exists. - lang = os.path.basename(os.path.dirname(mdFile.get_abspath())) - moFile = moByLang.get(lang) - htmlFile = env.markdown(mdFile) - env.Depends(htmlFile, mdFile) - if moFile: - env.Depends(htmlFile, moFile) - env.Depends(addon, htmlFile) - -# Pot target -i18nFiles = expandGlobs(buildVars.i18nSources) -gettextvars = { - "gettext_package_bugs_address": "nvda-translations@groups.io", - "gettext_package_name": buildVars.addon_info["addon_name"], - "gettext_package_version": env["addon_version"], -} - - -releaseVars = { - "version": None, - "channel": None, -} - -pot = env.gettextPotFile("${addon_name}.pot", i18nFiles, **gettextvars) -env.Alias("pot", pot) -env.Alias("release", release, **releaseVars) -env.Depends(pot, i18nFiles) -mergePot = env.gettextMergePotFile("${addon_name}-merge.pot", i18nFiles, **gettextvars) -env.Alias("mergePot", mergePot) -env.Depends(mergePot, i18nFiles) - -# Generate Manifest path -manifest = env.NVDAManifest( - os.path.join("addon", "manifest.ini"), os.path.join("manifest.ini.tpl") -) -# Ensure manifest is rebuilt if buildVars is updated. -env.Depends(manifest, "buildVars.py") - -env.Depends(addon, manifest) -env.Default(addon) -env.Clean(addon, [".sconsign.dblite", "addon/doc/" + buildVars.baseLanguage + "/"]) diff --git a/site_scons/site_tools/gettexttool/__init__.py b/site_scons/site_tools/gettexttool/__init__.py deleted file mode 100644 index 900f8dc..0000000 --- a/site_scons/site_tools/gettexttool/__init__.py +++ /dev/null @@ -1,55 +0,0 @@ -"""This tool allows generation of gettext .mo compiled files, pot files from source code files -and pot files for merging. - -Three new builders are added into the constructed environment: - -- gettextMoFile: generates .mo file from .pot file using msgfmt. -- gettextPotFile: Generates .pot file from source code files. -- gettextMergePotFile: Creates a .pot file appropriate for merging into existing .po files. - -To properly configure get text, define the following variables: - -- gettext_package_bugs_address -- gettext_package_name -- gettext_package_version - - -""" - -from SCons.Action import Action - - -def exists(env): - return True - - -XGETTEXT_COMMON_ARGS = ( - "--msgid-bugs-address='$gettext_package_bugs_address' " - "--package-name='$gettext_package_name' " - "--package-version='$gettext_package_version' " - "--keyword=pgettext:1c,2 " - "-c -o $TARGET $SOURCES" -) - - -def generate(env): - env.SetDefault(gettext_package_bugs_address="example@example.com") - env.SetDefault(gettext_package_name="") - env.SetDefault(gettext_package_version="") - - env["BUILDERS"]["gettextMoFile"] = env.Builder( - action=Action("msgfmt -o $TARGET $SOURCE", "Compiling translation $SOURCE"), - suffix=".mo", - src_suffix=".po", - ) - - env["BUILDERS"]["gettextPotFile"] = env.Builder( - action=Action("xgettext " + XGETTEXT_COMMON_ARGS, "Generating pot file $TARGET"), suffix=".pot" - ) - - env["BUILDERS"]["gettextMergePotFile"] = env.Builder( - action=Action( - "xgettext " + "--omit-header --no-location " + XGETTEXT_COMMON_ARGS, "Generating pot file $TARGET" - ), - suffix=".pot", - ) diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..22c830c --- /dev/null +++ b/uv.lock @@ -0,0 +1,92 @@ +version = 1 +revision = 3 +requires-python = ">=3.13" + +[[package]] +name = "babel" +version = "2.18.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/b2/51899539b6ceeeb420d40ed3cd4b7a40519404f9baf3d4ac99dc413a834b/babel-2.18.0.tar.gz", hash = "sha256:b80b99a14bd085fcacfa15c9165f651fbb3406e66cc603abf11c5750937c992d", size = 9959554, upload-time = "2026-02-01T12:30:56.078Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/f5/21d2de20e8b8b0408f0681956ca2c69f1320a3848ac50e6e7f39c6159675/babel-2.18.0-py3-none-any.whl", hash = "sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35", size = 10196845, upload-time = "2026-02-01T12:30:53.445Z" }, +] + +[[package]] +name = "instanttranslate-dev" +version = "0.0.0" +source = { virtual = "." } +dependencies = [ + { name = "nvda-addon-kit" }, +] + +[package.dev-dependencies] +dev = [ + { name = "ruff" }, +] +lint = [ + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [{ name = "nvda-addon-kit" }] + +[package.metadata.requires-dev] +dev = [{ name = "ruff", specifier = "==0.16.5" }] +lint = [{ name = "ruff", specifier = "==0.16.5" }] + +[[package]] +name = "markdown" +version = "3.10.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/29/6f/da4c6aea59b3001f2e8c0ec7497475aadaf3b021c10cab5b2858f0f32b26/markdown-3.10.3.tar.gz", hash = "sha256:3589362618f743188b4d955b874402bc814f4f83f544dc207719f4baa7d9c45f", size = 372596, upload-time = "2026-07-30T19:05:29.005Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/69/4a5af2bc115a9a33fefe51709749de8262be3f9ba063d1753a837cdbc49c/markdown-3.10.3-py3-none-any.whl", hash = "sha256:fa6c92a00a4a3c98b22728c64a935ae1928250ae65058a6ded814d2cc29a4cea", size = 110757, upload-time = "2026-07-30T19:05:27.883Z" }, +] + +[[package]] +name = "nvda-addon-kit" +version = "0.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "babel" }, + { name = "markdown" }, + { name = "tomlkit" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/65/8c/425a06cae05de36dd4bf2fb94badd57bc4d617e8ddbbaff7331a43ee2940/nvda_addon_kit-0.2.0.tar.gz", hash = "sha256:4ea606b4b0f9b531a76ea1683dd74bd329567c83536d13680cc5b6ec642650b8", size = 57891, upload-time = "2026-08-31T03:56:02.051Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/70/81/f652d60fff5b4d8ac2b7e5dc1d8bcb31d515f31e6562e0cad0f06c933ac3/nvda_addon_kit-0.2.0-py3-none-any.whl", hash = "sha256:33984db96d67748fe5c46cb2ac00d69a177904372c91691d0621fb339db7c5b2", size = 78689, upload-time = "2026-08-31T03:56:00.883Z" }, +] + +[[package]] +name = "ruff" +version = "0.16.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/85/c8e12473c93018f92d19dd988a294202e1c27426c47ec4de53ffb847b8d8/ruff-0.16.5.tar.gz", hash = "sha256:1b88500f9ffbcab3dedb0082c9f9492e91ec3d618aac1236a3e0189938f7040b", size = 4912003, upload-time = "2026-08-27T16:34:18.258Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c6/b6/77c90a970fe2dae17a723acbd011043ea97c98d7deacccefdc4ba74ec512/ruff-0.16.5-py3-none-linux_armv6l.whl", hash = "sha256:12e5f673e774c35fbb62f288809c7653b73445f8ecec6b6063fd6ea3521aa14b", size = 10011941, upload-time = "2026-08-27T16:33:41.287Z" }, + { url = "https://files.pythonhosted.org/packages/4b/46/6cf67cf6411885a1d6f7f6d801682f155536a85176d10b605e2ceffed8bd/ruff-0.16.5-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:eda58a5802de40e7ed5b32b64e0b32539338cc6fcd2c78f61e3ad6a0d79f51c3", size = 10204049, upload-time = "2026-08-27T16:33:44.056Z" }, + { url = "https://files.pythonhosted.org/packages/46/fd/c8720ca7a090abf0c2fef4abe8a5ef6e5127ed15196d8886ff75a2b370e2/ruff-0.16.5-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c5ae9a7b9a8875131f40f8fe967cc86abf899779efd663cb7ce3d572d01da7eb", size = 9809037, upload-time = "2026-08-27T16:33:46.257Z" }, + { url = "https://files.pythonhosted.org/packages/43/45/a684caacdedaca180f52bacccc40bf0789d2c5a7c75f25324853e9eaedb5/ruff-0.16.5-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7b719b0a1f4d59710d283ab2965f621684a108a9e41da622e3b23f0326cd0025", size = 9964129, upload-time = "2026-08-27T16:33:48.352Z" }, + { url = "https://files.pythonhosted.org/packages/9e/f2/5d2bcdaca6b5b93d1b4dfc166cd2aebf7680143a1b38a28759df13a94d31/ruff-0.16.5-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2298f2780ed1be0c5cb1361e32ab7b1467f3cce7dabe101d2210a314f2fe42e9", size = 9821518, upload-time = "2026-08-27T16:33:50.57Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ff/011cce29accf9257d5974145b733fc653a37985ed6825413a3987cefbfe0/ruff-0.16.5-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:258f29035a2dd021e7861e631b227a5b3f14e50c1184c9a6a122c5f4576154d7", size = 10534835, upload-time = "2026-08-27T16:33:52.522Z" }, + { url = "https://files.pythonhosted.org/packages/d7/5a/f0cf109bada9bba0e96c90c21c9f9251803f57225c32d293327a03c710d6/ruff-0.16.5-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b9a4f0432966834019c74d1b7e5c51224305d7713f3d7faf3e7451f1a3be3cde", size = 11252550, upload-time = "2026-08-27T16:33:54.521Z" }, + { url = "https://files.pythonhosted.org/packages/63/4d/1d481aaea2046c6a7ed7c291f9004c669cce3c087b6b376ed5b08271e3fe/ruff-0.16.5-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b5eb3a8c3d0ade9cea42b591fd530368e8798380e30e0a308b85a5cf718f09ea", size = 10777949, upload-time = "2026-08-27T16:33:56.88Z" }, + { url = "https://files.pythonhosted.org/packages/ee/34/ee245ca55f64443233034b3d02b03236b19242004281247c079390b7facd/ruff-0.16.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ef0f69e191a13a3c9816f63163c88790cb12cd157bbbb384e9c44745702ab105", size = 10311656, upload-time = "2026-08-27T16:33:59.12Z" }, + { url = "https://files.pythonhosted.org/packages/a7/4d/c33a333e341c0a2b96c715b52d89a606f5a34cd4ac493cd9b8d0187186b8/ruff-0.16.5-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:0eeab41fbea2c42f98dfb9822cdccda9d24ba38d49f6dc945b5c236d48f0ef29", size = 10532125, upload-time = "2026-08-27T16:34:01.166Z" }, + { url = "https://files.pythonhosted.org/packages/30/e1/a64cef78b40192497bb98a27a8aa8f2c98ee9ee15bc97f7712d94ef32937/ruff-0.16.5-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:f0768e9df4300713fff30733c87575f68b6f1d8de41184e505b7fdd9c0c95eaf", size = 10097648, upload-time = "2026-08-27T16:34:03.16Z" }, + { url = "https://files.pythonhosted.org/packages/cc/4e/4cdc9ed3c3e109d2f71e62572a37457298d7bc7501ec3138babb7ed32bbd/ruff-0.16.5-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:95cc70cdc7aa80c338de356279d2adbeb2de0f520b9ecd8aba75b94e95e02f91", size = 9829344, upload-time = "2026-08-27T16:34:05.134Z" }, + { url = "https://files.pythonhosted.org/packages/39/4a/31ed35ce31729955fc583ee0d176d6e784c1290cb0b0a75cb2134c1ab72a/ruff-0.16.5-py3-none-musllinux_1_2_i686.whl", hash = "sha256:d185c8398ded1bfd91c0c2cb258346307571eccc473a8490af8c3977399c384a", size = 10277117, upload-time = "2026-08-27T16:34:07.425Z" }, + { url = "https://files.pythonhosted.org/packages/a8/a0/60356d86687b4b666d593df213f4dc3041750d024cb7bf2cfa81cfd65c2e/ruff-0.16.5-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:fb8e3a3c4c6a784150a7ced53b015f4b253fc2bf97a610886419ead64b4756ef", size = 10711653, upload-time = "2026-08-27T16:34:09.712Z" }, + { url = "https://files.pythonhosted.org/packages/ed/20/656d67f5b25ca9bda4e02b1de25867b2954e1d19e03648060f167ad0f4cc/ruff-0.16.5-py3-none-win32.whl", hash = "sha256:288b0a5f080492fe5635db849f9e2e84aa3cce7b7f0e955997d416c507c76a26", size = 10034250, upload-time = "2026-08-27T16:34:11.8Z" }, + { url = "https://files.pythonhosted.org/packages/5b/42/ee8e68a207b9127fcde6c3d7e197def432f346cb1af159e1fa14ca0d1cdc/ruff-0.16.5-py3-none-win_amd64.whl", hash = "sha256:ddc6385fb2137f616357ca03d6c74f4be987f80fed4008566b754f6032b8546f", size = 10516714, upload-time = "2026-08-27T16:34:13.963Z" }, + { url = "https://files.pythonhosted.org/packages/73/e3/7df5a396e445b9ba49ce9a9437439a4d80042c61c0ade199abf8d16de1ac/ruff-0.16.5-py3-none-win_arm64.whl", hash = "sha256:a64abe90968719b851bb7cedffaa8753fbdbdadab483089682db623f3edc587e", size = 10391564, upload-time = "2026-08-27T16:34:16.064Z" }, +] + +[[package]] +name = "tomlkit" +version = "0.15.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/96/e07752635b98536177fa1f37671c8f3cdde2e724c6bcf6034b2cfb571565/tomlkit-0.15.1.tar.gz", hash = "sha256:e25bbf38843005246210a12982776f27f99cb9be67160e14434d0c0d21ee1e97", size = 180129, upload-time = "2026-07-17T01:48:04.562Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/bc/8c13eb66537dce1d2bd3a57132902f38d0e7f5bb46fa9f4daed9fe9d76ee/tomlkit-0.15.1-py3-none-any.whl", hash = "sha256:177a05aece5a8ca5266fd3c448abb47b8d352f09d477d3ca8332db4d89b24304", size = 49449, upload-time = "2026-07-17T01:48:05.728Z" }, +] From 383116f20bbb8c194e25c6218f53dbf197045914 Mon Sep 17 00:00:00 2001 From: Beka Gozalishvili Date: Mon, 31 Aug 2026 08:19:40 +0400 Subject: [PATCH 2/2] Reformat and fix lint checks --- .../instantTranslate/__init__.py | 1315 +++++++++-------- .../instantTranslate/donate_dialog.py | 27 +- .../instantTranslate/httpClient.py | 254 ++-- .../instantTranslate/interface.py | 636 ++++---- .../instantTranslate/langslist.py | 523 +++---- .../instantTranslate/languagePairs.py | 59 +- .../instantTranslate/providers.py | 60 +- .../instantTranslate/speechOnDemand.py | 84 +- .../instantTranslate/translators/base.py | 525 +++---- .../instantTranslate/translators/deepl.py | 74 +- .../instantTranslate/translators/google.py | 88 +- addon/installTasks.py | 10 +- 12 files changed, 1923 insertions(+), 1732 deletions(-) diff --git a/addon/globalPlugins/instantTranslate/__init__.py b/addon/globalPlugins/instantTranslate/__init__.py index 2b7236d..cf05bf6 100644 --- a/addon/globalPlugins/instantTranslate/__init__.py +++ b/addon/globalPlugins/instantTranslate/__init__.py @@ -1,11 +1,11 @@ -#__init__.py +# __init__.py # Copyright (C) 2012-2026 Aleksey Sadovoy AKA Lex , -#ruslan , -#Beka Gozalishvili -#This addon was been repacked and optimized for executing without standalone Python by Outsider . -#other nvda contributors -#This file is covered by the GNU General Public License. -#See the file COPYING for more details. +# ruslan , +# Beka Gozalishvili +# This addon was been repacked and optimized for executing without standalone Python by Outsider . +# other nvda contributors +# This file is covered by the GNU General Public License. +# See the file COPYING for more details. from collections import OrderedDict, namedtuple from functools import wraps @@ -24,9 +24,7 @@ import globalPluginHandler import globalVars import gui -import json import languageHandler -import os import queueHandler import scriptHandler import textInfos @@ -34,685 +32,748 @@ import tones import ui from speech import speak + try: - from speech.commands import LangChangeCommand -except: - from speech import LangChangeCommand + from speech.commands import LangChangeCommand +except ImportError: + from speech import LangChangeCommand import braille import wx import speechViewer + try: - # For NVDA 2021.1 and above - from speech import speech + # For NVDA 2021.1 and above + from speech import speech except ImportError: - # For NVDA 2020.4 and below - import speech + # For NVDA 2020.4 and below + import speech _curAddon = addonHandler.getCodeAddon() addonName = _curAddon.name.lower() -_addonSummary = _curAddon.manifest['summary'] +_addonSummary = _curAddon.manifest["summary"] addonHandler.initTranslation() + def getLocaleLanguage(): - lang = getlocale()[0] or languageHandler.getLanguage() or "en" - if lang == "zh_HK": - return "zh-TW" - elif lang.startswith("zh"): - return lang.replace('_', '-') - else: - return lang.split("_")[0] + lang = getlocale()[0] or languageHandler.getLanguage() or "en" + if lang == "zh_HK": + return "zh-TW" + elif lang.startswith("zh"): + return lang.replace("_", "-") + else: + return lang.split("_")[0] + confspec = { -"from": "string(default=auto)", -"into": f"string(default={getLocaleLanguage()})", -"swap": "string(default=en)", -"copytranslatedtext": "boolean(default=true)", -"autoswap": "boolean(default=true)", -"isautoswapped": "boolean(default=false)", -"replaceUnderscores": "boolean(default=false)", -"progressbeeps": "boolean(default=true)", -"pairs": "string_list(default=list())", -"provider": "string(default=google)", -"announcestart": "boolean(default=true)", + "from": "string(default=auto)", + "into": f"string(default={getLocaleLanguage()})", + "swap": "string(default=en)", + "copytranslatedtext": "boolean(default=true)", + "autoswap": "boolean(default=true)", + "isautoswapped": "boolean(default=false)", + "replaceUnderscores": "boolean(default=false)", + "progressbeeps": "boolean(default=true)", + "pairs": "string_list(default=list())", + "provider": "string(default=google)", + "announcestart": "boolean(default=true)", } class ConfigOption: - def __init__(self, key): - if key not in confspec: - raise KeyError("%r is not an Instant Translate configuration key" % (key,)) - self.key = key + def __init__(self, key): + if key not in confspec: + raise KeyError("%r is not an Instant Translate configuration key" % (key,)) + self.key = key - def __get__(self, plugin, pluginType=None): - if plugin is None: - return self - return plugin.addonConf[self.key] + def __get__(self, plugin, pluginType=None): + if plugin is None: + return self + return plugin.addonConf[self.key] - def __set__(self, plugin, value): - plugin.addonConf[self.key] = value + def __set__(self, plugin, value): + plugin.addonConf[self.key] = value # Define speakOnDemand parameter for all scripts needing it speakOnDemand = getSpeechOnDemandParameter() TRANSLATION_CACHE_SIZE = 100 -TranslationResult = namedtuple("TranslationResult", ("translation", "langTo", "detectedLanguage")) +TranslationResult = namedtuple( + "TranslationResult", ("translation", "langTo", "detectedLanguage") +) PROGRESS_BEEP_INTERVAL = 1 PROGRESS_BEEP_PITCH = 500 PROGRESS_BEEP_DURATION = 100 -TranslationProgress = namedtuple("TranslationProgress", ("done", "total", "remaining", "percent")) +TranslationProgress = namedtuple( + "TranslationProgress", ("done", "total", "remaining", "percent") +) -LayerCommand = namedtuple("LayerCommand", ("keys", "label", "scriptName", "description")) +LayerCommand = namedtuple( + "LayerCommand", ("keys", "label", "scriptName", "description") +) SCRIPT_PREFIX = "script_" def layerScript(key, description, label=None, **scriptKwargs): - keys = (key,) if isinstance(key, str) else tuple(key) - if not keys: - raise ValueError("A layer command needs at least one key") + keys = (key,) if isinstance(key, str) else tuple(key) + if not keys: + raise ValueError("A layer command needs at least one key") - def decorate(script): - script = scriptHandler.script(description=description, **scriptKwargs)(script) - script.layerKeys = keys - script.layerLabel = label or ", ".join(keys) - return script + def decorate(script): + script = scriptHandler.script(description=description, **scriptKwargs)(script) + script.layerKeys = keys + script.layerLabel = label or ", ".join(keys) + return script - return decorate + return decorate def getLayerCommands(pluginType): - commands = {} - for pluginClass in reversed(pluginType.__mro__): - for name, member in vars(pluginClass).items(): - keys = getattr(member, "layerKeys", None) - if keys is None or not name.startswith(SCRIPT_PREFIX): - continue - commands[name] = LayerCommand( - keys, member.layerLabel, name[len(SCRIPT_PREFIX):], member.__doc__ or "" - ) - return list(commands.values()) + commands = {} + for pluginClass in reversed(pluginType.__mro__): + for name, member in vars(pluginClass).items(): + keys = getattr(member, "layerKeys", None) + if keys is None or not name.startswith(SCRIPT_PREFIX): + continue + commands[name] = LayerCommand( + keys, + member.layerLabel, + name[len(SCRIPT_PREFIX) :], + member.__doc__ or "", + ) + return list(commands.values()) class ProgressBeeper: - def __init__(self, interval=PROGRESS_BEEP_INTERVAL): - self.interval = interval - self._lock = threading.Lock() - self._stopEvent = None - - def start(self): - with self._lock: - if self._stopEvent is not None: - return - stopEvent = self._stopEvent = threading.Event() - def tick(): - while not stopEvent.wait(self.interval): - beep(PROGRESS_BEEP_PITCH, PROGRESS_BEEP_DURATION) - threading.Thread(target=tick, daemon=True, name="InstantTranslateProgressBeeper").start() - - def stop(self): - with self._lock: - stopEvent, self._stopEvent = self._stopEvent, None - if stopEvent is not None: - stopEvent.set() + def __init__(self, interval=PROGRESS_BEEP_INTERVAL): + self.interval = interval + self._lock = threading.Lock() + self._stopEvent = None + + def start(self): + with self._lock: + if self._stopEvent is not None: + return + stopEvent = self._stopEvent = threading.Event() + + def tick(): + while not stopEvent.wait(self.interval): + beep(PROGRESS_BEEP_PITCH, PROGRESS_BEEP_DURATION) + + threading.Thread( + target=tick, daemon=True, name="InstantTranslateProgressBeeper" + ).start() + + def stop(self): + with self._lock: + stopEvent, self._stopEvent = self._stopEvent, None + if stopEvent is not None: + stopEvent.set() # Below toggle code came from Tyler Spivey's code, with enhancements by Joseph Lee. + def finally_(func, final): - """Calls final after func, even if it fails.""" - @wraps(func) - def new(*args, **kwargs): - try: - func(*args, **kwargs) - finally: - final() - return new - -#def detect_language(text): -# response=urllib.urlopen("https://translate.yandex.net/api/v1.5/tr.json/detect?key=trnsl.1.1.20150410T053856Z.1c57628dc3007498.d36b0117d8315e9cab26f8e0302f6055af8132d7&"+urllib.urlencode({"text":text.encode('utf-8')})).read() -# response=json.loads(response) -# return response['lang'] + """Calls final after func, even if it fails.""" + + @wraps(func) + def new(*args, **kwargs): + try: + func(*args, **kwargs) + finally: + final() + + return new + + +# def detect_language(text): +# response=urllib.urlopen("https://translate.yandex.net/api/v1.5/tr.json/detect?key=trnsl.1.1.20150410T053856Z.1c57628dc3007498.d36b0117d8315e9cab26f8e0302f6055af8132d7&"+urllib.urlencode({"text":text.encode('utf-8')})).read() +# response=json.loads(response) +# return response['lang'] + def messageWithLangDetection(msg): - autoLanguageSwitching=config.conf['speech']['autoLanguageSwitching'] - if autoLanguageSwitching: - speechSequence=[] - speechSequence.append(LangChangeCommand(msg['lang'])) - speechSequence.append(msg['text']) - speak(speechSequence) - braille.handler.message(msg['text']) - else: - ui.message(msg['text']) + autoLanguageSwitching = config.conf["speech"]["autoLanguageSwitching"] + if autoLanguageSwitching: + speechSequence = [] + speechSequence.append(LangChangeCommand(msg["lang"])) + speechSequence.append(msg["text"]) + speak(speechSequence) + braille.handler.message(msg["text"]) + else: + ui.message(msg["text"]) class GlobalPlugin(globalPluginHandler.GlobalPlugin): - scriptCategory = _addonSummary - - lang_from = ConfigOption("from") - lang_to = ConfigOption("into") - lang_swap = ConfigOption("swap") - copyTranslation = ConfigOption("copytranslatedtext") - autoSwap = ConfigOption("autoswap") - isAutoSwapped = ConfigOption("isautoswapped") - replaceUnderscores = ConfigOption("replaceUnderscores") - progressBeeps = ConfigOption("progressbeeps") - configuredPairs = ConfigOption("pairs") - provider = ConfigOption("provider") - announceStart = ConfigOption("announcestart") - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - if globalVars.appArgs.secure: - return - config.conf.spec[addonName] = confspec - self.addonConf = config.conf[addonName] - self.toggling = False - self.lastTranslation = None - self._translationCache = OrderedDict() - self._translationCacheLock = threading.Lock() - self._activeTranslators = set() - self._activeTranslatorsLock = threading.Lock() - self._beeper = ProgressBeeper() - InstantTranslateSettingsPanel.addonConf = self.addonConf - gui.settingsDialogs.NVDASettingsDialog.categoryClasses.append(InstantTranslateSettingsPanel) - self._speak = speech.speak - speech.speak = self._localSpeak - self.lastSpokenText = '' - self.autoTranslate = False - languageCache.get() - - def getScript(self, gesture): - if not self.toggling: - return globalPluginHandler.GlobalPlugin.getScript(self, gesture) - script = globalPluginHandler.GlobalPlugin.getScript(self, gesture) - if not script: - script = finally_(self.script_error, self.finish) - return finally_(script, self.finish) - - def finish(self): - self.toggling = False - self.clearGestureBindings() - self.bindGestures(self.__gestures) - - def script_error(self, gesture): - tones.beep(120, 100) - - @scriptHandler.script( - description=_("Instant Translate layer commands. Then press h to list available commands.") - ) - def script_ITLayer(self, gesture): - # A run-time binding will occur from which we can perform various layered translation commands. - # First, check if a second press of the script was done. - if self.toggling: - self.script_error(gesture) - return - self.bindGestures(self.getLayerGestures()) - self.toggling = True - tones.beep(100, 10) - - def getLayerGestures(self): - return { - "kb:%s" % key: command.scriptName - for command in getLayerCommands(type(self)) - for key in command.keys - } - - def getLayerHelp(self): - items = "".join( - "
  • %s: %s
  • " % (escape(command.label), escape(command.description)) - for command in getLayerCommands(type(self)) - ) - return "
      %s
    " % items - - def terminate(self): - self.stopTranslations() - gui.settingsDialogs.NVDASettingsDialog.categoryClasses.remove(InstantTranslateSettingsPanel) - speech.speak = self._speak - - @layerScript( - "shift+t", - # Translators: Description of a layer command, presented in input help mode and in the command list. - description=_("Translates the clipboard text from one language to another."), - **speakOnDemand, - ) - def script_translateClipboardText(self, gesture): - try: - text = api.getClipData() - except: - text = None - if not text or not isinstance(text, str) or text.isspace(): - # Translators: message presented when user presses the shortcut key for translating clipboard text but the clipboard is empty. - ui.message(_("There is no text on the clipboard")) - else: - self.translate(text, self.lang_from, self.lang_to) - - def getSelectedText(self): - obj=api.getCaretObject() - try: - info=obj.makeTextInfo(textInfos.POSITION_SELECTION) - if info or not info.isCollapsed: - return info.text - except (RuntimeError, NotImplementedError): - return None - - @layerScript( - "t", - # Translators: Description of a layer command, presented in input help mode and in the command list. - description=_("Translates the selected text from one language to another."), - **speakOnDemand, - ) - def script_translateSelection(self, gesture): - text = self.getSelectedText() - if not text: - # Translators: user has pressed the shortcut key for translating selected text, but no text was actually selected. - ui.message(_("no selection")) - return - self.translate(text, self.lang_from, self.lang_to) - - def translate(self, text, langFrom, langTo): - if self.replaceUnderscores: - text = text.replace("_", " ") - if langFrom == "auto" and self.autoSwap: - langSwap = self.lang_swap - else: - langSwap = None - key = (self.provider, text, langFrom, langTo, langSwap) - cached = self.cacheLookup(key) - if cached is not None: - self.announceTranslation(cached) - return - if self.announceStart: - # Translators: message presented when a translation has been requested and its result is - # awaited, unless the corresponding setting has been unchecked. - ui.message(_("Translation started, please wait")) - self.startTranslator(self.translatorClass( - langFrom, - langTo, - text, - langSwap, - onSuccess=lambda translator: self.translationSucceeded(key, translator), - onError=self.translationFailed, - )) - - def translationSucceeded(self, key, translator): - result = TranslationResult(translator.translation, translator.langTo, translator.detectedLanguage) - self.cacheStore(key, result) - self.announceTranslation(result) - - def translationFailed(self, translator): - # Translators: message presented when the translation service could not be reached. - queueHandler.queueFunction(queueHandler.eventQueue, ui.message, _("Translation failed")) - - def announceTranslation(self, result): - self.lastTranslation = result.translation - msgTranslation = {'text': result.translation, 'lang': result.langTo} - queueHandler.queueFunction(queueHandler.eventQueue, lambda: executeWithSpeakOnDemand(messageWithLangDetection, msgTranslation)) - self.copyResult(result.translation) - - def translateAndCache(self, text, langFrom, langTo, langSwap=None): - if langFrom != "auto" and langSwap is not None: - raise RuntimeError("Unexpected arguments: langFrom={}, langTo={}, langSwap={}, text={}".format(langFrom, langTo, langSwap, text)) - # useful for yandex, that doesn't support auto option -# if langFrom == "auto": -# langFrom = detect_language(text) - key = (self.provider, text, langFrom, langTo, langSwap) - cached = self.cacheLookup(key) - if cached is not None: - return cached - myTranslator = self.startTranslator(self.translatorClass(langFrom, langTo, text, langSwap)) - myTranslator.join() - if myTranslator.shouldStop: - raise RuntimeError('Translation stopped') - if myTranslator.error: - if not self.autoTranslate: - queueHandler.queueFunction(queueHandler.eventQueue, ui.message, _("Translation failed")) - raise RuntimeError('Translation failure') - result = TranslationResult(myTranslator.translation, myTranslator.langTo, myTranslator.detectedLanguage) - self.cacheStore(key, result) - return result - - def startTranslator(self, translator): - translator.onFinished = self._translatorFinished - with self._activeTranslatorsLock: - self._activeTranslators.add(translator) - isFirst = len(self._activeTranslators) == 1 - if isFirst and self.progressBeeps: - self._beeper.start() - translator.start() - return translator - - def _translatorFinished(self, translator): - with self._activeTranslatorsLock: - self._activeTranslators.discard(translator) - wasLast = not self._activeTranslators - if wasLast: - self._beeper.stop() - - def translationStatus(self): - with self._activeTranslatorsLock: - translators = list(self._activeTranslators) - if not translators: - return None - done = sum(translator.completedChunks for translator in translators) - total = sum(translator.totalChunks for translator in translators) - percent = 100 if not total else int(round(done * 100.0 / total)) - return TranslationProgress(done, total, total - done, percent) - - def stopTranslations(self): - with self._activeTranslatorsLock: - translators = list(self._activeTranslators) - for translator in translators: - translator.stop() - return len(translators) - - def cacheLookup(self, key): - with self._translationCacheLock: - result = self._translationCache.get(key) - if result is not None: - self._translationCache.move_to_end(key) - return result - - def cacheStore(self, key, result): - with self._translationCacheLock: - self._translationCache[key] = result - while len(self._translationCache) > TRANSLATION_CACHE_SIZE: - self._translationCache.popitem(last=False) - - def clearTranslationCache(self): - with self._translationCacheLock: - count = len(self._translationCache) - self._translationCache.clear() - return count - - def copyResult(self, translation, ignoreSetting=False): - if ignoreSetting: - api.copyToClip(translation) - elif self.copyTranslation: - api.copyToClip(translation) - - def swapLanguages(self, langFrom, langTo): - self.lang_from, self.lang_to = langTo, langFrom - - @layerScript( - "s", - # Translators: Description of a layer command, presented in input help mode and in the command list. - description=_("Swaps the source and target languages."), - ) - def script_swapLanguages(self, gesture): - if self.lang_from == "auto": - self.swapLanguages(self.lang_swap, self.lang_to) - self.isAutoSwapped = True - elif self.isAutoSwapped and self.lang_to == self.lang_swap: - self.swapLanguages(self.lang_from, "auto") - self.isAutoSwapped = False - else: - self.swapLanguages(self.lang_from, self.lang_to) - # Translators: message presented to announce that the source and target languages have been swapped. - ui.message(_("Languages swapped")) - self.announceLanguages() - self.translateSelectionUnlessOnDemand(gesture) - - def translateSelectionUnlessOnDemand(self, gesture): - try: - # NVDA 2024.1+ - shouldTranslate = speech.getState().speechMode != speech.SpeechMode.onDemand - except AttributeError: - # NVDA <= 2023.3 - shouldTranslate = True - if shouldTranslate: - self.script_translateSelection(gesture) - - def announceLanguages(self): - ui.message( - # Translators: message presented to announce the current source and target languages. - _("Translate: from {lang1} to {lang2}").format( - lang1=getLanguageName(self.lang_from, short=True), - lang2=getLanguageName(self.lang_to, short=True), - ) - ) - - @property - def translatorClass(self): - return getProvider(self.provider) - - @property - def languagePairs(self): - return parsePairs(self.configuredPairs) - - def useLanguagePair(self, slot, gesture): - pairs = self.languagePairs - if slot >= len(pairs): - ui.message( - # Translators: message presented when the user switches to a slot holding no language pair yet. {slot} is the number of that slot, from 1 to 10. - _("No language pair in slot {slot}").format(slot=slot + 1) - ) - return - pair = pairs[slot] - self.lang_from, self.lang_to = pair.langFrom, pair.langTo - self.isAutoSwapped = False - self.announceLanguages() - self.translateSelectionUnlessOnDemand(gesture) - - @layerScript( - "n", - # Translators: Description of a layer command, presented in input help mode and in the command list. - description=_("Switches to the next translation service."), - **speakOnDemand, - ) - def script_nextProvider(self, gesture): - self.provider = nextProvider(self.provider) - self.announceProvider() - - def announceProvider(self): - ui.message( - # Translators: message presented when switching to another translation service. - _("Using service: {provider}").format(provider=self.translatorClass.providerName) - ) - - @layerScript( - "a", - # Translators: Description of a layer command, presented in input help mode and in the command list. - description=_("Announces the current source and target languages."), - **speakOnDemand, - ) - def script_announceLanguages(self, gesture): - self.announceLanguages() - - @layerScript( - "c", - # Translators: Description of a layer command, presented in input help mode and in the command list. - description=_("Copies the last translation to the clipboard."), - ) - def script_copyLastResult(self, gesture): - if self.lastTranslation: - self.copyResult(self.lastTranslation, ignoreSetting=True) - # Translators: message presented to announce a successful copy - ui.message(_("Last translation copied in clipboard")) - else: - # Translators: message presented to announce no previous translation disponibility - ui.message(_("No stored translation")) - - @layerScript( - "i", - # Translators: Description of a layer command, presented in input help mode and in the command list. - description=_("Identifies the language of the selected text."), - **speakOnDemand, - ) - def script_identifyLanguage(self, gesture): - text = self.getSelectedText() - if not text: - # Translators: user has pressed the shortcut key for translating selected text, but no text was actually selected. - ui.message(_("no selection")) - return - ui.message(_("Language is...")) - self.startTranslator(self.translatorClass( - "auto", - self.lang_to, - text, - onSuccess=self.languageIdentified, - onError=self.translationFailed, - )) - - def languageIdentified(self, translator): - language = translator.detectedLanguage - if not language: - self.translationFailed(translator) - return - queueHandler.queueFunction( - queueHandler.eventQueue, executeWithSpeakOnDemand, ui.message, getLanguageName(language) - ) - - def _localSpeak(self, sequence, *args, **kwargs): - text_items = [x for x in sequence if isinstance(x, str)] - self.lastSpokenText = speechViewer.SPEECH_ITEM_SEPARATOR.join(text_items) - if self.autoTranslate and text_items: - text_to_translate = self.lastSpokenText - if self.replaceUnderscores: - text_to_translate = text_to_translate.replace("_", " ") - # Perform translation synchronously - try: - result = self.translateAndCache(text_to_translate, self.lang_from, self.lang_to) - translated_text = result.translation - # Create a new sequence with the translated text - new_sequence = [] - if config.conf['speech']['autoLanguageSwitching']: - new_sequence.append(LangChangeCommand(result.langTo)) - new_sequence.append(translated_text) - self._speak(new_sequence, *args, **kwargs) - # Optionally, copy the result - self.copyResult(translated_text) - except RuntimeError: - # If translation fails, speak the original sequence - self._speak(sequence, *args, **kwargs) - else: - self._speak(sequence, *args, **kwargs) - - @layerScript( - "l", - # Translators: Description of a layer command, presented in input help mode and in the command list. - description=_("Translates the last spoken text."), - **speakOnDemand, - ) - def script_translateLastSpokenText(self, gesture): - if self.lastSpokenText: - self.translate(self.lastSpokenText, self.lang_from, self.lang_to) - - @layerScript( - "p", - # Translators: Description of a layer command, presented in input help mode and in the command list. - description=_("Announces how far the translation in progress has got."), - **speakOnDemand, - ) - def script_translationStatus(self, gesture): - progress = self.translationStatus() - if progress is None: - # Translators: message presented when the user asks about a translation, but none is running. - ui.message(_("No translation in progress")) - return - ui.message( - # Translators: message presented to announce how far the translation in progress has got. - # {done} parts are translated out of {total}, {remaining} are left and {percent} is a percentage. - _("Translating: {done} of {total} parts done, {remaining} remaining, {percent}%").format( - done=progress.done, - total=progress.total, - remaining=progress.remaining, - percent=progress.percent, - ) - ) - - @layerScript( - "x", - # Translators: Description of a layer command, presented in input help mode and in the command list. - description=_("Stops the translations in progress."), - **speakOnDemand, - ) - def script_stopTranslation(self, gesture): - if self.stopTranslations(): - # Translators: message presented to announce that the translations in progress were cancelled. - ui.message(_("Translation stopped")) - else: - # Translators: message presented when the user asks to stop translating, but nothing is running. - ui.message(_("No translation in progress")) - - @layerScript( - "r", - # Translators: Description of a layer command, presented in input help mode and in the command list. - description=_("Clears the cache of recent translations."), - **speakOnDemand, - ) - def script_clearTranslationCache(self, gesture): - self.clearTranslationCache() - # Translators: message presented to announce that the cache of recent translations has been emptied. - ui.message(_("Translation cache cleared")) - - @layerScript( - "h", - # Translators: Description of a layer command, presented in input help mode and in the command list. - description=_("Announces all available layer commands."), - **speakOnDemand, - ) - def script_displayHelp(self, gesture): - ui.browseableMessage( - self.getLayerHelp(), - # Translators: title of the window listing the commands of the add-on. - _("Instant Translate commands"), - isHtml=True, - ) - - @layerScript( - "o", - # Translators: Description of a layer command, presented in input help mode and in the command list. - description=_("Opens the Instant Translate settings dialog."), - ) - def script_showSettings(self, gesture): - try: - # NVDA version >= 2023.2 - popupSettingsDialog = gui.mainFrame.popupSettingsDialog - except: - # NVDA version < 2023.2 - popupSettingsDialog = gui.mainFrame._popupSettingsDialog - wx.CallAfter(popupSettingsDialog, gui.settingsDialogs.NVDASettingsDialog, InstantTranslateSettingsPanel) - - @layerScript( - "v", - # Translators: Description of a layer command, presented in input help mode and in the command list. - description=_("Toggles automatic translation of speech output."), - ) - def script_toggleAutoTranslate(self, gesture): - self.autoTranslate = not self.autoTranslate - if self.autoTranslate: - # Translators: message presented to announce that automatic translation is enabled. - ui.message(_("Automatic translation enabled")) - else: - # Translators: message presented to announce that automatic translation is disabled. - ui.message(_("Automatic translation disabled")) - - __gestures = { - "kb:NVDA+shift+t": "ITLayer", - } + scriptCategory = _addonSummary + + lang_from = ConfigOption("from") + lang_to = ConfigOption("into") + lang_swap = ConfigOption("swap") + copyTranslation = ConfigOption("copytranslatedtext") + autoSwap = ConfigOption("autoswap") + isAutoSwapped = ConfigOption("isautoswapped") + replaceUnderscores = ConfigOption("replaceUnderscores") + progressBeeps = ConfigOption("progressbeeps") + configuredPairs = ConfigOption("pairs") + provider = ConfigOption("provider") + announceStart = ConfigOption("announcestart") + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + if globalVars.appArgs.secure: + return + config.conf.spec[addonName] = confspec + self.addonConf = config.conf[addonName] + self.toggling = False + self.lastTranslation = None + self._translationCache = OrderedDict() + self._translationCacheLock = threading.Lock() + self._activeTranslators = set() + self._activeTranslatorsLock = threading.Lock() + self._beeper = ProgressBeeper() + InstantTranslateSettingsPanel.addonConf = self.addonConf + gui.settingsDialogs.NVDASettingsDialog.categoryClasses.append( + InstantTranslateSettingsPanel + ) + self._speak = speech.speak + speech.speak = self._localSpeak + self.lastSpokenText = "" + self.autoTranslate = False + languageCache.get() + + def getScript(self, gesture): + if not self.toggling: + return globalPluginHandler.GlobalPlugin.getScript(self, gesture) + script = globalPluginHandler.GlobalPlugin.getScript(self, gesture) + if not script: + script = finally_(self.script_error, self.finish) + return finally_(script, self.finish) + + def finish(self): + self.toggling = False + self.clearGestureBindings() + self.bindGestures(self.__gestures) + + def script_error(self, gesture): + tones.beep(120, 100) + + @scriptHandler.script( + description=_( + "Instant Translate layer commands. Then press h to list available commands." + ) + ) + def script_ITLayer(self, gesture): + # A run-time binding will occur from which we can perform various layered translation commands. + # First, check if a second press of the script was done. + if self.toggling: + self.script_error(gesture) + return + self.bindGestures(self.getLayerGestures()) + self.toggling = True + tones.beep(100, 10) + + def getLayerGestures(self): + return { + "kb:%s" % key: command.scriptName + for command in getLayerCommands(type(self)) + for key in command.keys + } + + def getLayerHelp(self): + items = "".join( + "
  • %s: %s
  • " + % (escape(command.label), escape(command.description)) + for command in getLayerCommands(type(self)) + ) + return "
      %s
    " % items + + def terminate(self): + self.stopTranslations() + gui.settingsDialogs.NVDASettingsDialog.categoryClasses.remove( + InstantTranslateSettingsPanel + ) + speech.speak = self._speak + + @layerScript( + "shift+t", + # Translators: Description of a layer command, presented in input help mode and in the command list. + description=_("Translates the clipboard text from one language to another."), + **speakOnDemand, + ) + def script_translateClipboardText(self, gesture): + try: + text = api.getClipData() + except Exception: + text = None + if not text or not isinstance(text, str) or text.isspace(): + # Translators: message presented when user presses the shortcut key for translating clipboard text but the clipboard is empty. + ui.message(_("There is no text on the clipboard")) + else: + self.translate(text, self.lang_from, self.lang_to) + + def getSelectedText(self): + obj = api.getCaretObject() + try: + info = obj.makeTextInfo(textInfos.POSITION_SELECTION) + if info or not info.isCollapsed: + return info.text + except (RuntimeError, NotImplementedError): + return None + + @layerScript( + "t", + # Translators: Description of a layer command, presented in input help mode and in the command list. + description=_("Translates the selected text from one language to another."), + **speakOnDemand, + ) + def script_translateSelection(self, gesture): + text = self.getSelectedText() + if not text: + # Translators: user has pressed the shortcut key for translating selected text, but no text was actually selected. + ui.message(_("no selection")) + return + self.translate(text, self.lang_from, self.lang_to) + + def translate(self, text, langFrom, langTo): + if self.replaceUnderscores: + text = text.replace("_", " ") + if langFrom == "auto" and self.autoSwap: + langSwap = self.lang_swap + else: + langSwap = None + key = (self.provider, text, langFrom, langTo, langSwap) + cached = self.cacheLookup(key) + if cached is not None: + self.announceTranslation(cached) + return + if self.announceStart: + # Translators: message presented when a translation has been requested and its result is + # awaited, unless the corresponding setting has been unchecked. + ui.message(_("Translation started, please wait")) + self.startTranslator( + self.translatorClass( + langFrom, + langTo, + text, + langSwap, + onSuccess=lambda translator: self.translationSucceeded(key, translator), + onError=self.translationFailed, + ) + ) + + def translationSucceeded(self, key, translator): + result = TranslationResult( + translator.translation, translator.langTo, translator.detectedLanguage + ) + self.cacheStore(key, result) + self.announceTranslation(result) + + def translationFailed(self, translator): + # Translators: message presented when the translation service could not be reached. + queueHandler.queueFunction( + queueHandler.eventQueue, ui.message, _("Translation failed") + ) + + def announceTranslation(self, result): + self.lastTranslation = result.translation + msgTranslation = {"text": result.translation, "lang": result.langTo} + queueHandler.queueFunction( + queueHandler.eventQueue, + lambda: executeWithSpeakOnDemand(messageWithLangDetection, msgTranslation), + ) + self.copyResult(result.translation) + + def translateAndCache(self, text, langFrom, langTo, langSwap=None): + if langFrom != "auto" and langSwap is not None: + raise RuntimeError( + "Unexpected arguments: langFrom={}, langTo={}, langSwap={}, text={}".format( + langFrom, langTo, langSwap, text + ) + ) + # useful for yandex, that doesn't support auto option + # if langFrom == "auto": + # langFrom = detect_language(text) + key = (self.provider, text, langFrom, langTo, langSwap) + cached = self.cacheLookup(key) + if cached is not None: + return cached + myTranslator = self.startTranslator( + self.translatorClass(langFrom, langTo, text, langSwap) + ) + myTranslator.join() + if myTranslator.shouldStop: + raise RuntimeError("Translation stopped") + if myTranslator.error: + if not self.autoTranslate: + queueHandler.queueFunction( + queueHandler.eventQueue, ui.message, _("Translation failed") + ) + raise RuntimeError("Translation failure") + result = TranslationResult( + myTranslator.translation, myTranslator.langTo, myTranslator.detectedLanguage + ) + self.cacheStore(key, result) + return result + + def startTranslator(self, translator): + translator.onFinished = self._translatorFinished + with self._activeTranslatorsLock: + self._activeTranslators.add(translator) + isFirst = len(self._activeTranslators) == 1 + if isFirst and self.progressBeeps: + self._beeper.start() + translator.start() + return translator + + def _translatorFinished(self, translator): + with self._activeTranslatorsLock: + self._activeTranslators.discard(translator) + wasLast = not self._activeTranslators + if wasLast: + self._beeper.stop() + + def translationStatus(self): + with self._activeTranslatorsLock: + translators = list(self._activeTranslators) + if not translators: + return None + done = sum(translator.completedChunks for translator in translators) + total = sum(translator.totalChunks for translator in translators) + percent = 100 if not total else int(round(done * 100.0 / total)) + return TranslationProgress(done, total, total - done, percent) + + def stopTranslations(self): + with self._activeTranslatorsLock: + translators = list(self._activeTranslators) + for translator in translators: + translator.stop() + return len(translators) + + def cacheLookup(self, key): + with self._translationCacheLock: + result = self._translationCache.get(key) + if result is not None: + self._translationCache.move_to_end(key) + return result + + def cacheStore(self, key, result): + with self._translationCacheLock: + self._translationCache[key] = result + while len(self._translationCache) > TRANSLATION_CACHE_SIZE: + self._translationCache.popitem(last=False) + + def clearTranslationCache(self): + with self._translationCacheLock: + count = len(self._translationCache) + self._translationCache.clear() + return count + + def copyResult(self, translation, ignoreSetting=False): + if ignoreSetting: + api.copyToClip(translation) + elif self.copyTranslation: + api.copyToClip(translation) + + def swapLanguages(self, langFrom, langTo): + self.lang_from, self.lang_to = langTo, langFrom + + @layerScript( + "s", + # Translators: Description of a layer command, presented in input help mode and in the command list. + description=_("Swaps the source and target languages."), + ) + def script_swapLanguages(self, gesture): + if self.lang_from == "auto": + self.swapLanguages(self.lang_swap, self.lang_to) + self.isAutoSwapped = True + elif self.isAutoSwapped and self.lang_to == self.lang_swap: + self.swapLanguages(self.lang_from, "auto") + self.isAutoSwapped = False + else: + self.swapLanguages(self.lang_from, self.lang_to) + # Translators: message presented to announce that the source and target languages have been swapped. + ui.message(_("Languages swapped")) + self.announceLanguages() + self.translateSelectionUnlessOnDemand(gesture) + + def translateSelectionUnlessOnDemand(self, gesture): + try: + # NVDA 2024.1+ + shouldTranslate = speech.getState().speechMode != speech.SpeechMode.onDemand + except AttributeError: + # NVDA <= 2023.3 + shouldTranslate = True + if shouldTranslate: + self.script_translateSelection(gesture) + + def announceLanguages(self): + ui.message( + # Translators: message presented to announce the current source and target languages. + _("Translate: from {lang1} to {lang2}").format( + lang1=getLanguageName(self.lang_from, short=True), + lang2=getLanguageName(self.lang_to, short=True), + ) + ) + + @property + def translatorClass(self): + return getProvider(self.provider) + + @property + def languagePairs(self): + return parsePairs(self.configuredPairs) + + def useLanguagePair(self, slot, gesture): + pairs = self.languagePairs + if slot >= len(pairs): + ui.message( + # Translators: message presented when the user switches to a slot holding no language pair yet. {slot} is the number of that slot, from 1 to 10. + _("No language pair in slot {slot}").format(slot=slot + 1) + ) + return + pair = pairs[slot] + self.lang_from, self.lang_to = pair.langFrom, pair.langTo + self.isAutoSwapped = False + self.announceLanguages() + self.translateSelectionUnlessOnDemand(gesture) + + @layerScript( + "n", + # Translators: Description of a layer command, presented in input help mode and in the command list. + description=_("Switches to the next translation service."), + **speakOnDemand, + ) + def script_nextProvider(self, gesture): + self.provider = nextProvider(self.provider) + self.announceProvider() + + def announceProvider(self): + ui.message( + # Translators: message presented when switching to another translation service. + _("Using service: {provider}").format( + provider=self.translatorClass.providerName + ) + ) + + @layerScript( + "a", + # Translators: Description of a layer command, presented in input help mode and in the command list. + description=_("Announces the current source and target languages."), + **speakOnDemand, + ) + def script_announceLanguages(self, gesture): + self.announceLanguages() + + @layerScript( + "c", + # Translators: Description of a layer command, presented in input help mode and in the command list. + description=_("Copies the last translation to the clipboard."), + ) + def script_copyLastResult(self, gesture): + if self.lastTranslation: + self.copyResult(self.lastTranslation, ignoreSetting=True) + # Translators: message presented to announce a successful copy + ui.message(_("Last translation copied in clipboard")) + else: + # Translators: message presented to announce no previous translation disponibility + ui.message(_("No stored translation")) + + @layerScript( + "i", + # Translators: Description of a layer command, presented in input help mode and in the command list. + description=_("Identifies the language of the selected text."), + **speakOnDemand, + ) + def script_identifyLanguage(self, gesture): + text = self.getSelectedText() + if not text: + # Translators: user has pressed the shortcut key for translating selected text, but no text was actually selected. + ui.message(_("no selection")) + return + ui.message(_("Language is...")) + self.startTranslator( + self.translatorClass( + "auto", + self.lang_to, + text, + onSuccess=self.languageIdentified, + onError=self.translationFailed, + ) + ) + + def languageIdentified(self, translator): + language = translator.detectedLanguage + if not language: + self.translationFailed(translator) + return + queueHandler.queueFunction( + queueHandler.eventQueue, + executeWithSpeakOnDemand, + ui.message, + getLanguageName(language), + ) + + def _localSpeak(self, sequence, *args, **kwargs): + text_items = [x for x in sequence if isinstance(x, str)] + self.lastSpokenText = speechViewer.SPEECH_ITEM_SEPARATOR.join(text_items) + if self.autoTranslate and text_items: + text_to_translate = self.lastSpokenText + if self.replaceUnderscores: + text_to_translate = text_to_translate.replace("_", " ") + # Perform translation synchronously + try: + result = self.translateAndCache( + text_to_translate, self.lang_from, self.lang_to + ) + translated_text = result.translation + # Create a new sequence with the translated text + new_sequence = [] + if config.conf["speech"]["autoLanguageSwitching"]: + new_sequence.append(LangChangeCommand(result.langTo)) + new_sequence.append(translated_text) + self._speak(new_sequence, *args, **kwargs) + # Optionally, copy the result + self.copyResult(translated_text) + except RuntimeError: + # If translation fails, speak the original sequence + self._speak(sequence, *args, **kwargs) + else: + self._speak(sequence, *args, **kwargs) + + @layerScript( + "l", + # Translators: Description of a layer command, presented in input help mode and in the command list. + description=_("Translates the last spoken text."), + **speakOnDemand, + ) + def script_translateLastSpokenText(self, gesture): + if self.lastSpokenText: + self.translate(self.lastSpokenText, self.lang_from, self.lang_to) + + @layerScript( + "p", + # Translators: Description of a layer command, presented in input help mode and in the command list. + description=_("Announces how far the translation in progress has got."), + **speakOnDemand, + ) + def script_translationStatus(self, gesture): + progress = self.translationStatus() + if progress is None: + # Translators: message presented when the user asks about a translation, but none is running. + ui.message(_("No translation in progress")) + return + ui.message( + # Translators: message presented to announce how far the translation in progress has got. + # {done} parts are translated out of {total}, {remaining} are left and {percent} is a percentage. + _( + "Translating: {done} of {total} parts done, {remaining} remaining, {percent}%" + ).format( + done=progress.done, + total=progress.total, + remaining=progress.remaining, + percent=progress.percent, + ) + ) + + @layerScript( + "x", + # Translators: Description of a layer command, presented in input help mode and in the command list. + description=_("Stops the translations in progress."), + **speakOnDemand, + ) + def script_stopTranslation(self, gesture): + if self.stopTranslations(): + # Translators: message presented to announce that the translations in progress were cancelled. + ui.message(_("Translation stopped")) + else: + # Translators: message presented when the user asks to stop translating, but nothing is running. + ui.message(_("No translation in progress")) + + @layerScript( + "r", + # Translators: Description of a layer command, presented in input help mode and in the command list. + description=_("Clears the cache of recent translations."), + **speakOnDemand, + ) + def script_clearTranslationCache(self, gesture): + self.clearTranslationCache() + # Translators: message presented to announce that the cache of recent translations has been emptied. + ui.message(_("Translation cache cleared")) + + @layerScript( + "h", + # Translators: Description of a layer command, presented in input help mode and in the command list. + description=_("Announces all available layer commands."), + **speakOnDemand, + ) + def script_displayHelp(self, gesture): + ui.browseableMessage( + self.getLayerHelp(), + # Translators: title of the window listing the commands of the add-on. + _("Instant Translate commands"), + isHtml=True, + ) + + @layerScript( + "o", + # Translators: Description of a layer command, presented in input help mode and in the command list. + description=_("Opens the Instant Translate settings dialog."), + ) + def script_showSettings(self, gesture): + try: + # NVDA version >= 2023.2 + popupSettingsDialog = gui.mainFrame.popupSettingsDialog + except AttributeError: + # NVDA version < 2023.2 + popupSettingsDialog = gui.mainFrame._popupSettingsDialog + wx.CallAfter( + popupSettingsDialog, + gui.settingsDialogs.NVDASettingsDialog, + InstantTranslateSettingsPanel, + ) + + @layerScript( + "v", + # Translators: Description of a layer command, presented in input help mode and in the command list. + description=_("Toggles automatic translation of speech output."), + ) + def script_toggleAutoTranslate(self, gesture): + self.autoTranslate = not self.autoTranslate + if self.autoTranslate: + # Translators: message presented to announce that automatic translation is enabled. + ui.message(_("Automatic translation enabled")) + else: + # Translators: message presented to announce that automatic translation is disabled. + ui.message(_("Automatic translation disabled")) + + __gestures = { + "kb:NVDA+shift+t": "ITLayer", + } def makeLanguagePairScript(slot): - def script(self, gesture): - self.useLanguagePair(slot, gesture) + def script(self, gesture): + self.useLanguagePair(slot, gesture) - script.__name__ = "%suseLanguagePair%d" % (SCRIPT_PREFIX, slot + 1) - return layerScript( - PAIR_KEYS[slot], - # Translators: Description of a layer command, presented in input help mode and in the command - # list. {number} is the number of the language pair, from 1 to 10. - description=_("Switches to the language pair {number} configured in the settings.").format( - number=slot + 1 - ), - **speakOnDemand, - )(script) + script.__name__ = "%suseLanguagePair%d" % (SCRIPT_PREFIX, slot + 1) + return layerScript( + PAIR_KEYS[slot], + # Translators: Description of a layer command, presented in input help mode and in the command + # list. {number} is the number of the language pair, from 1 to 10. + description=_( + "Switches to the language pair {number} configured in the settings." + ).format(number=slot + 1), + **speakOnDemand, + )(script) for _slot in range(MAX_PAIRS): - _pairScript = makeLanguagePairScript(_slot) - setattr(GlobalPlugin, _pairScript.__name__, _pairScript) + _pairScript = makeLanguagePairScript(_slot) + setattr(GlobalPlugin, _pairScript.__name__, _pairScript) del _slot, _pairScript diff --git a/addon/globalPlugins/instantTranslate/donate_dialog.py b/addon/globalPlugins/instantTranslate/donate_dialog.py index 57386f1..d7dc89a 100644 --- a/addon/globalPlugins/instantTranslate/donate_dialog.py +++ b/addon/globalPlugins/instantTranslate/donate_dialog.py @@ -1,17 +1,19 @@ -#donate_dialog.py +# donate_dialog.py # Copyright (C) 2022-2023 Beqa Gozalishvili -#This file is covered by the GNU General Public License. -#See the file COPYING for more details. +# This file is covered by the GNU General Public License. +# See the file COPYING for more details. import addonHandler -import gui + try: # NVDA version >= 2025.1 from gui.message import MessageDialog, DialogType + DIALOG_TYPE_WARNING = DialogType.WARNING except ImportError: # NVDA version < 2025.1 from gui.nvdaControls import MessageDialog + DIALOG_TYPE_WARNING = MessageDialog.DIALOG_TYPE_WARNING import webbrowser import wx @@ -27,8 +29,12 @@ def __init__(self, parent, title, message): super().__init__(parent, title, message, dialogType=DIALOG_TYPE_WARNING) def _addButtons(self, buttonHelper): - paypalBtn = buttonHelper.addButton(self, label=_("Donate via Paypal"), name="PAYPAL_URL") - yoomoneyBtn = buttonHelper.addButton(self, label=_("Donate via Yoomoney"), name="YOOMONEY_URL") + paypalBtn = buttonHelper.addButton( + self, label=_("Donate via Paypal"), name="PAYPAL_URL" + ) + yoomoneyBtn = buttonHelper.addButton( + self, label=_("Donate via Yoomoney"), name="YOOMONEY_URL" + ) paypalBtn.Bind(wx.EVT_BUTTON, self.onDonate) yoomoneyBtn.Bind(wx.EVT_BUTTON, self.onDonate) cancelBtn = buttonHelper.addButton(self, id=wx.ID_CANCEL) @@ -40,11 +46,14 @@ def onDonate(self, evt): webbrowser.open(donateUrl) self.EndModal(wx.OK) + def requestDonations(parentWindow): addon = addonHandler.getCodeAddon() addonName = addon.name title = _("Request for contributions to {name}").format(name=addonName) - message = _("{name} is a free add-on for NVDA.\n" - "You can make a donation to its author to support further development of this and other free projects.\n" - "Do you want to donate now? Choose one of the available payment methods. You will be redirected to the corresponding website to complete a donation").format(name=addonName) + message = _( + "{name} is a free add-on for NVDA.\n" + "You can make a donation to its author to support further development of this and other free projects.\n" + "Do you want to donate now? Choose one of the available payment methods. You will be redirected to the corresponding website to complete a donation" + ).format(name=addonName) return DonationDialog(parentWindow, title, message).ShowModal() diff --git a/addon/globalPlugins/instantTranslate/httpClient.py b/addon/globalPlugins/instantTranslate/httpClient.py index 5f5d0a0..c9bcd19 100644 --- a/addon/globalPlugins/instantTranslate/httpClient.py +++ b/addon/globalPlugins/instantTranslate/httpClient.py @@ -13,148 +13,154 @@ class Cancelled(Exception): - pass + pass class SessionClosed(Cancelled): - pass + pass class RequestAborted(Cancelled): - pass + pass def buildUrl(url, params=None): - if not params: - return url - return "%s?%s" % (url, urlencode(params)) + if not params: + return url + return "%s?%s" % (url, urlencode(params)) class Response: - def __init__(self, raw, onClose=None): - self._raw = raw - self._onClose = onClose - self._aborted = False - - @property - def status(self): - return self._raw.status - - @property - def headers(self): - return self._raw.headers - - def read(self): - try: - with self: - return self._raw.read() - except Exception: - if self._aborted: - raise RequestAborted("The request was aborted") from None - raise - - def text(self, encoding="utf-8"): - return self.read().decode(encoding) - - def json(self): - return jsonLib.loads(self.text()) - - def close(self): - try: - self._raw.close() - finally: - onClose, self._onClose = self._onClose, None - if onClose is not None: - onClose(self) - - def abort(self): - self._aborted = True - rawSocket = getattr(getattr(self._raw, "fp", None), "raw", None) - rawSocket = getattr(rawSocket, "_sock", None) - if rawSocket is not None: - try: - rawSocket.shutdown(socket.SHUT_RDWR) - except OSError: - pass - self.close() - - def __enter__(self): - return self - - def __exit__(self, *excInfo): - self.close() + def __init__(self, raw, onClose=None): + self._raw = raw + self._onClose = onClose + self._aborted = False + + @property + def status(self): + return self._raw.status + + @property + def headers(self): + return self._raw.headers + + def read(self): + try: + with self: + return self._raw.read() + except Exception: + if self._aborted: + raise RequestAborted("The request was aborted") from None + raise + + def text(self, encoding="utf-8"): + return self.read().decode(encoding) + + def json(self): + return jsonLib.loads(self.text()) + + def close(self): + try: + self._raw.close() + finally: + onClose, self._onClose = self._onClose, None + if onClose is not None: + onClose(self) + + def abort(self): + self._aborted = True + rawSocket = getattr(getattr(self._raw, "fp", None), "raw", None) + rawSocket = getattr(rawSocket, "_sock", None) + if rawSocket is not None: + try: + rawSocket.shutdown(socket.SHUT_RDWR) + except OSError: + pass + self.close() + + def __enter__(self): + return self + + def __exit__(self, *excInfo): + self.close() class Session: - def __init__(self, headers=None, timeout=DEFAULT_TIMEOUT): - self.headers = {"User-agent": USER_AGENT} - if headers: - self.headers.update(headers) - self.timeout = timeout - self._lock = threading.Lock() - self._response = None - self._closed = False - - @property - def closed(self): - with self._lock: - return self._closed - - def get(self, url, params=None, headers=None, timeout=None): - return self.request(url, params=params, headers=headers, timeout=timeout) - - def post(self, url, json=None, params=None, headers=None, timeout=None): - body = None if json is None else jsonLib.dumps(json).encode("utf-8") - allHeaders = {"Content-Type": "application/json"} - if headers: - allHeaders.update(headers) - return self.request(url, data=body, params=params, headers=allHeaders, timeout=timeout) - - def request(self, url, data=None, params=None, headers=None, timeout=None): - allHeaders = dict(self.headers) - if headers: - allHeaders.update(headers) - request = urllibRequest.Request(buildUrl(url, params), data=data, headers=allHeaders) - self._raiseIfClosed() - raw = urllibRequest.urlopen(request, timeout=self.timeout if timeout is None else timeout) - response = Response(raw, onClose=self._forget) - with self._lock: - if not self._closed: - self._response = response - return response - response.abort() - raise SessionClosed("The session was closed while the request was in flight") - - def close(self): - with self._lock: - if self._closed: - return - self._closed = True - response = self._response - self._response = None - if response is not None: - response.abort() - - def _raiseIfClosed(self): - with self._lock: - if self._closed: - raise SessionClosed("The session is closed") - - def _forget(self, response): - with self._lock: - if self._response is response: - self._response = None - - def __enter__(self): - return self - - def __exit__(self, *excInfo): - self.close() + def __init__(self, headers=None, timeout=DEFAULT_TIMEOUT): + self.headers = {"User-agent": USER_AGENT} + if headers: + self.headers.update(headers) + self.timeout = timeout + self._lock = threading.Lock() + self._response = None + self._closed = False + + @property + def closed(self): + with self._lock: + return self._closed + + def get(self, url, params=None, headers=None, timeout=None): + return self.request(url, params=params, headers=headers, timeout=timeout) + + def post(self, url, json=None, params=None, headers=None, timeout=None): + body = None if json is None else jsonLib.dumps(json).encode("utf-8") + allHeaders = {"Content-Type": "application/json"} + if headers: + allHeaders.update(headers) + return self.request( + url, data=body, params=params, headers=allHeaders, timeout=timeout + ) + + def request(self, url, data=None, params=None, headers=None, timeout=None): + allHeaders = dict(self.headers) + if headers: + allHeaders.update(headers) + request = urllibRequest.Request( + buildUrl(url, params), data=data, headers=allHeaders + ) + self._raiseIfClosed() + raw = urllibRequest.urlopen( + request, timeout=self.timeout if timeout is None else timeout + ) + response = Response(raw, onClose=self._forget) + with self._lock: + if not self._closed: + self._response = response + return response + response.abort() + raise SessionClosed("The session was closed while the request was in flight") + + def close(self): + with self._lock: + if self._closed: + return + self._closed = True + response = self._response + self._response = None + if response is not None: + response.abort() + + def _raiseIfClosed(self): + with self._lock: + if self._closed: + raise SessionClosed("The session is closed") + + def _forget(self, response): + with self._lock: + if self._response is response: + self._response = None + + def __enter__(self): + return self + + def __exit__(self, *excInfo): + self.close() def get(url, params=None, headers=None, timeout=DEFAULT_TIMEOUT): - return Session(timeout=timeout).get(url, params=params, headers=headers) + return Session(timeout=timeout).get(url, params=params, headers=headers) def post(url, json=None, params=None, headers=None, timeout=DEFAULT_TIMEOUT): - return Session(timeout=timeout).post(url, json=json, params=params, headers=headers) + return Session(timeout=timeout).post(url, json=json, params=params, headers=headers) diff --git a/addon/globalPlugins/instantTranslate/interface.py b/addon/globalPlugins/instantTranslate/interface.py index ef65772..25da3d2 100644 --- a/addon/globalPlugins/instantTranslate/interface.py +++ b/addon/globalPlugins/instantTranslate/interface.py @@ -1,10 +1,10 @@ -#interface.py +# interface.py # Copyright (C) 2012-2026 Aleksey Sadovoy AKA Lex , -#ruslan , -#Beka Gozalishvili -#other nvda contributors -#This file is covered by the GNU General Public License. -#See the file COPYING for more details. +# ruslan , +# Beka Gozalishvili +# other nvda contributors +# This file is covered by the GNU General Public License. +# See the file COPYING for more details. import wx import gui @@ -19,298 +19,368 @@ def sourceChoices(sourceLangs): - auto = g("auto") - return [auto] + [name for name in sourceLangs if name != auto] + auto = g("auto") + return [auto] + [name for name in sourceLangs if name != auto] def providerLanguages(providerId): - cache = getProvider(providerId).languageCache - return getLanguages("source", cache), getLanguages("target", cache) + cache = getProvider(providerId).languageCache + return getLanguages("source", cache), getLanguages("target", cache) def chosenCode(choice, languages, default): - return languages.get(choice.GetStringSelection(), default) + return languages.get(choice.GetStringSelection(), default) def getName(languages, code): - for name, languageCode in languages.items(): - if languageCode == code: - return name - return g("en") + for name, languageCode in languages.items(): + if languageCode == code: + return name + return g("en") class InstantTranslateSettingsPanel(SettingsPanel): - # Translators: name of the dialog. - title = _("Instant Translate") - - def makeSettings(self, sizer): - helper = gui.guiHelper.BoxSizerHelper(self, sizer=sizer) - self.provider = self.addonConf['provider'] - self.sourceLangs, self.targetLangs = providerLanguages(self.provider) - self.languagePairs = parsePairs(self.addonConf['pairs']) - - # Translators: Help message for a dialog. - helpLabel = wx.StaticText(self, label=_("Select translation source and target language:")) - sizer.Add(helpLabel) - - # Translators: A setting in addon settings dialog, selecting the translation service to use. - self._providerChoice = helper.addLabeledControl( - _("Translation service:"), wx.Choice, choices=[provider.providerName for provider in PROVIDERS.values()] - ) - self._providerChoice.Select(max(0, list(PROVIDERS).index(self.provider) if self.provider in PROVIDERS else 0)) - self._providerChoice.Bind(wx.EVT_CHOICE, self.onProviderSelect) - - # Translators: A setting in addon settings dialog. - fromLabelText = _("Source language:") - # list of choices, in alphabetical order but with auto in first position - self._fromChoice = helper.addLabeledControl(fromLabelText, wx.Choice, choices=sourceChoices(self.sourceLangs)) - - # Translators: A setting in addon settings dialog. - intoLabelText = _("Target language:") - self._intoChoice = helper.addLabeledControl(intoLabelText, wx.Choice, choices=list(self.targetLangs)) - - # Translators: A setting in addon settings dialog, shown if source language is on auto. - swapLabelText = _("Language for swapping:") - self._swapChoice = helper.addLabeledControl(swapLabelText, wx.Choice, choices=list(self.targetLangs)) - self._fromChoice.Bind(wx.EVT_CHOICE, self.onFromSelect) - - # Translators: A setting in addon settings dialog, shown if source language is on auto. - self.autoSwapChk = helper.addItem(wx.CheckBox(self, label=_("Activate the auto-swap if recognized source is equal to the target (experimental)"))) - self.autoSwapChk.SetValue(self.addonConf['autoswap']) - - # Translators: A setting in addon settings dialog. - self.copyTranslationChk = helper.addItem(wx.CheckBox(self, label=_("Copy translation result to clipboard"))) - self.copyTranslationChk.SetValue(self.addonConf['copytranslatedtext']) - - # Translators: A setting in addon settings dialog. - self.replaceUnderscores = helper.addItem(wx.CheckBox(self, label=_("Replace underscores with spaces (May provide better translation results depending on context)"))) - self.replaceUnderscores.SetValue(self.addonConf['replaceUnderscores']) - - # Translators: A setting in addon settings dialog. - self.progressBeepsChk = helper.addItem(wx.CheckBox(self, label=_("Beep while a translation is in progress"))) - self.progressBeepsChk.SetValue(self.addonConf['progressbeeps']) - - # Translators: A setting in addon settings dialog. - self.announceStartChk = helper.addItem(wx.CheckBox(self, label=_("Announce when a translation starts"))) - self.announceStartChk.SetValue(self.addonConf['announcestart']) - - # Translators: A button in addon settings dialog, opening the language pairs dialog. - self.pairsBtn = helper.addItem(wx.Button(self, label=_("Language &pairs..."))) - self.pairsBtn.Bind(wx.EVT_BUTTON, self.onLanguagePairs) - - # Translators: A setting in addon settings dialog. - self.donateBtn = helper.addItem(wx.Button(self, label=_("Support an author..."))) - self.donateBtn.Bind(wx.EVT_BUTTON, self.onDonate) - - iLang_from = self._fromChoice.FindString(getName(self.sourceLangs, self.addonConf['from'])) - iLang_to = self._intoChoice.FindString(getName(self.targetLangs, self.addonConf['into'])) - iLang_swap = self._swapChoice.FindString(getName(self.targetLangs, self.addonConf['swap'])) - self._fromChoice.Select(iLang_from) - self._intoChoice.Select(iLang_to) - self._swapChoice.Select(iLang_swap) - self.updateSwapControls() - - def postInit(self): - self._fromChoice.SetFocus() - - def onDonate(self, evt): - from .donate_dialog import requestDonations - requestDonations(self) - - def onLanguagePairs(self, evt): - dialog = LanguagePairsDialog(self, self.languagePairs, self.sourceLangs, self.targetLangs) - try: - if dialog.ShowModal() == wx.ID_OK: - self.languagePairs = dialog.pairs - finally: - dialog.Destroy() - self.pairsBtn.SetFocus() - - def onProviderSelect(self, event): - self.provider = list(PROVIDERS)[self._providerChoice.GetSelection()] - self.reloadLanguages() - - def reloadLanguages(self): - chosen = [ - chosenCode(self._fromChoice, self.sourceLangs, self.addonConf['from']), - chosenCode(self._intoChoice, self.targetLangs, self.addonConf['into']), - chosenCode(self._swapChoice, self.targetLangs, self.addonConf['swap']), - ] - self.sourceLangs, self.targetLangs = providerLanguages(self.provider) - self._fromChoice.Set(sourceChoices(self.sourceLangs)) - self._intoChoice.Set(list(self.targetLangs)) - self._swapChoice.Set(list(self.targetLangs)) - for choice, languages, code in zip( - (self._fromChoice, self._intoChoice, self._swapChoice), - (self.sourceLangs, self.targetLangs, self.targetLangs), - chosen, - ): - choice.Select(max(0, choice.FindString(getName(languages, code)))) - self.updateSwapControls() - - def updateSwapControls(self): - isAuto = self._fromChoice.GetStringSelection() == g("auto") - self._swapChoice.Enable(isAuto) - self.autoSwapChk.Enable(isAuto) - - def onFromSelect(self, event): - self.updateSwapControls() - - def onSave(self): - self.addonConf['from'] = self.sourceLangs[self._fromChoice.GetStringSelection()] - self.addonConf['into'] = self.targetLangs[self._intoChoice.GetStringSelection()] - self.addonConf['swap'] = self.targetLangs[self._swapChoice.GetStringSelection()] - self.addonConf['copytranslatedtext'] = self.copyTranslationChk.GetValue() - self.addonConf['autoswap'] = self.autoSwapChk.GetValue() - self.addonConf['replaceUnderscores'] = self.replaceUnderscores.GetValue() - self.addonConf['progressbeeps'] = self.progressBeepsChk.GetValue() - self.addonConf['announcestart'] = self.announceStartChk.GetValue() - self.addonConf['provider'] = self.provider - self.addonConf['pairs'] = formatPairs(self.languagePairs) - - def getName(self, languages, code): - return getName(languages, code) + # Translators: name of the dialog. + title = _("Instant Translate") + + def makeSettings(self, sizer): + helper = gui.guiHelper.BoxSizerHelper(self, sizer=sizer) + self.provider = self.addonConf["provider"] + self.sourceLangs, self.targetLangs = providerLanguages(self.provider) + self.languagePairs = parsePairs(self.addonConf["pairs"]) + + # Translators: Help message for a dialog. + helpLabel = wx.StaticText( + self, label=_("Select translation source and target language:") + ) + sizer.Add(helpLabel) + + # Translators: A setting in addon settings dialog, selecting the translation service to use. + self._providerChoice = helper.addLabeledControl( + _("Translation service:"), + wx.Choice, + choices=[provider.providerName for provider in PROVIDERS.values()], + ) + self._providerChoice.Select( + max( + 0, + list(PROVIDERS).index(self.provider) + if self.provider in PROVIDERS + else 0, + ) + ) + self._providerChoice.Bind(wx.EVT_CHOICE, self.onProviderSelect) + + # Translators: A setting in addon settings dialog. + fromLabelText = _("Source language:") + # list of choices, in alphabetical order but with auto in first position + self._fromChoice = helper.addLabeledControl( + fromLabelText, wx.Choice, choices=sourceChoices(self.sourceLangs) + ) + + # Translators: A setting in addon settings dialog. + intoLabelText = _("Target language:") + self._intoChoice = helper.addLabeledControl( + intoLabelText, wx.Choice, choices=list(self.targetLangs) + ) + + # Translators: A setting in addon settings dialog, shown if source language is on auto. + swapLabelText = _("Language for swapping:") + self._swapChoice = helper.addLabeledControl( + swapLabelText, wx.Choice, choices=list(self.targetLangs) + ) + self._fromChoice.Bind(wx.EVT_CHOICE, self.onFromSelect) + + # Translators: A setting in addon settings dialog, shown if source language is on auto. + self.autoSwapChk = helper.addItem( + wx.CheckBox( + self, + label=_( + "Activate the auto-swap if recognized source is equal to the target (experimental)" + ), + ) + ) + self.autoSwapChk.SetValue(self.addonConf["autoswap"]) + + # Translators: A setting in addon settings dialog. + self.copyTranslationChk = helper.addItem( + wx.CheckBox(self, label=_("Copy translation result to clipboard")) + ) + self.copyTranslationChk.SetValue(self.addonConf["copytranslatedtext"]) + + # Translators: A setting in addon settings dialog. + self.replaceUnderscores = helper.addItem( + wx.CheckBox( + self, + label=_( + "Replace underscores with spaces (May provide better translation results depending on context)" + ), + ) + ) + self.replaceUnderscores.SetValue(self.addonConf["replaceUnderscores"]) + + # Translators: A setting in addon settings dialog. + self.progressBeepsChk = helper.addItem( + wx.CheckBox(self, label=_("Beep while a translation is in progress")) + ) + self.progressBeepsChk.SetValue(self.addonConf["progressbeeps"]) + + # Translators: A setting in addon settings dialog. + self.announceStartChk = helper.addItem( + wx.CheckBox(self, label=_("Announce when a translation starts")) + ) + self.announceStartChk.SetValue(self.addonConf["announcestart"]) + + # Translators: A button in addon settings dialog, opening the language pairs dialog. + self.pairsBtn = helper.addItem(wx.Button(self, label=_("Language &pairs..."))) + self.pairsBtn.Bind(wx.EVT_BUTTON, self.onLanguagePairs) + + # Translators: A setting in addon settings dialog. + self.donateBtn = helper.addItem( + wx.Button(self, label=_("Support an author...")) + ) + self.donateBtn.Bind(wx.EVT_BUTTON, self.onDonate) + + iLang_from = self._fromChoice.FindString( + getName(self.sourceLangs, self.addonConf["from"]) + ) + iLang_to = self._intoChoice.FindString( + getName(self.targetLangs, self.addonConf["into"]) + ) + iLang_swap = self._swapChoice.FindString( + getName(self.targetLangs, self.addonConf["swap"]) + ) + self._fromChoice.Select(iLang_from) + self._intoChoice.Select(iLang_to) + self._swapChoice.Select(iLang_swap) + self.updateSwapControls() + + def postInit(self): + self._fromChoice.SetFocus() + + def onDonate(self, evt): + from .donate_dialog import requestDonations + + requestDonations(self) + + def onLanguagePairs(self, evt): + dialog = LanguagePairsDialog( + self, self.languagePairs, self.sourceLangs, self.targetLangs + ) + try: + if dialog.ShowModal() == wx.ID_OK: + self.languagePairs = dialog.pairs + finally: + dialog.Destroy() + self.pairsBtn.SetFocus() + + def onProviderSelect(self, event): + self.provider = list(PROVIDERS)[self._providerChoice.GetSelection()] + self.reloadLanguages() + + def reloadLanguages(self): + chosen = [ + chosenCode(self._fromChoice, self.sourceLangs, self.addonConf["from"]), + chosenCode(self._intoChoice, self.targetLangs, self.addonConf["into"]), + chosenCode(self._swapChoice, self.targetLangs, self.addonConf["swap"]), + ] + self.sourceLangs, self.targetLangs = providerLanguages(self.provider) + self._fromChoice.Set(sourceChoices(self.sourceLangs)) + self._intoChoice.Set(list(self.targetLangs)) + self._swapChoice.Set(list(self.targetLangs)) + for choice, languages, code in zip( + (self._fromChoice, self._intoChoice, self._swapChoice), + (self.sourceLangs, self.targetLangs, self.targetLangs), + chosen, + ): + choice.Select(max(0, choice.FindString(getName(languages, code)))) + self.updateSwapControls() + + def updateSwapControls(self): + isAuto = self._fromChoice.GetStringSelection() == g("auto") + self._swapChoice.Enable(isAuto) + self.autoSwapChk.Enable(isAuto) + + def onFromSelect(self, event): + self.updateSwapControls() + + def onSave(self): + self.addonConf["from"] = self.sourceLangs[self._fromChoice.GetStringSelection()] + self.addonConf["into"] = self.targetLangs[self._intoChoice.GetStringSelection()] + self.addonConf["swap"] = self.targetLangs[self._swapChoice.GetStringSelection()] + self.addonConf["copytranslatedtext"] = self.copyTranslationChk.GetValue() + self.addonConf["autoswap"] = self.autoSwapChk.GetValue() + self.addonConf["replaceUnderscores"] = self.replaceUnderscores.GetValue() + self.addonConf["progressbeeps"] = self.progressBeepsChk.GetValue() + self.addonConf["announcestart"] = self.announceStartChk.GetValue() + self.addonConf["provider"] = self.provider + self.addonConf["pairs"] = formatPairs(self.languagePairs) + + def getName(self, languages, code): + return getName(languages, code) class LanguagePairsDialog(wx.Dialog): - def __init__(self, parent, pairs, sourceLangs, targetLangs): - # Translators: title of the dialog managing the language pairs. - super().__init__(parent, title=_("Language pairs")) - self.pairs = list(pairs) - self.sourceLangs = sourceLangs - self.targetLangs = targetLangs - - mainSizer = wx.BoxSizer(wx.VERTICAL) - helper = gui.guiHelper.BoxSizerHelper(self, orientation=wx.VERTICAL) - - # Translators: label of the list of language pairs, in the language pairs dialog. - listLabel = _("Language &pairs, activated by the keys 1 to 0 of the Instant Translate layer:") - self.pairsList = helper.addLabeledControl(listLabel, wx.ListBox, choices=[], style=wx.LB_SINGLE) - self.pairsList.Bind(wx.EVT_LISTBOX, self.onSelect) - self.pairsList.Bind(wx.EVT_LISTBOX_DCLICK, self.onEdit) - - buttonHelper = gui.guiHelper.ButtonHelper(wx.HORIZONTAL) - # Translators: a button of the language pairs dialog. - self.addBtn = buttonHelper.addButton(self, label=_("&Add")) - # Translators: a button of the language pairs dialog. - self.editBtn = buttonHelper.addButton(self, label=_("&Edit")) - # Translators: a button of the language pairs dialog. - self.removeBtn = buttonHelper.addButton(self, label=_("&Remove")) - # Translators: a button of the language pairs dialog, moving a pair one slot up. - self.upBtn = buttonHelper.addButton(self, label=_("Move &up")) - # Translators: a button of the language pairs dialog, moving a pair one slot down. - self.downBtn = buttonHelper.addButton(self, label=_("Move &down")) - self.addBtn.Bind(wx.EVT_BUTTON, self.onAdd) - self.editBtn.Bind(wx.EVT_BUTTON, self.onEdit) - self.removeBtn.Bind(wx.EVT_BUTTON, self.onRemove) - self.upBtn.Bind(wx.EVT_BUTTON, self.onMoveUp) - self.downBtn.Bind(wx.EVT_BUTTON, self.onMoveDown) - helper.addItem(buttonHelper) - - helper.addDialogDismissButtons(self.CreateButtonSizer(wx.OK | wx.CANCEL)) - mainSizer.Add(helper.sizer, border=gui.guiHelper.BORDER_FOR_DIALOGS, flag=wx.ALL) - mainSizer.Fit(self) - self.SetSizer(mainSizer) - self.refresh(0 if self.pairs else wx.NOT_FOUND) - self.CentreOnScreen() - self.pairsList.SetFocus() - - def refresh(self, selection): - self.pairsList.Set([slotLabel(index, pair) for index, pair in enumerate(self.pairs)]) - if self.pairs: - self.pairsList.SetSelection(max(0, min(selection, len(self.pairs) - 1))) - else: - self.pairsList.SetSelection(wx.NOT_FOUND) - self.updateButtons() - - def updateButtons(self): - index = self.pairsList.GetSelection() - hasSelection = index != wx.NOT_FOUND - self.addBtn.Enable(len(self.pairs) < MAX_PAIRS) - self.editBtn.Enable(hasSelection) - self.removeBtn.Enable(hasSelection) - self.upBtn.Enable(hasSelection and index > 0) - self.downBtn.Enable(hasSelection and index < len(self.pairs) - 1) - - def onSelect(self, evt): - self.updateButtons() - - def editPair(self, pair=None): - dialog = LanguagePairDialog(self, self.sourceLangs, self.targetLangs, pair) - try: - return dialog.pair if dialog.ShowModal() == wx.ID_OK else None - finally: - dialog.Destroy() - - def onAdd(self, evt): - if len(self.pairs) >= MAX_PAIRS: - return - pair = self.editPair() - if pair is not None: - self.pairs.append(pair) - self.refresh(len(self.pairs) - 1) - self.pairsList.SetFocus() - - def onEdit(self, evt): - index = self.pairsList.GetSelection() - if index == wx.NOT_FOUND: - return - pair = self.editPair(self.pairs[index]) - if pair is not None: - self.pairs[index] = pair - self.refresh(index) - self.pairsList.SetFocus() - - def onRemove(self, evt): - index = self.pairsList.GetSelection() - if index == wx.NOT_FOUND: - return - del self.pairs[index] - self.refresh(index) - self.pairsList.SetFocus() - - def onMoveUp(self, evt): - self.move(-1) - - def onMoveDown(self, evt): - self.move(1) - - def move(self, offset): - index = self.pairsList.GetSelection() - target = index + offset - if index == wx.NOT_FOUND or not 0 <= target < len(self.pairs): - return - self.pairs[index], self.pairs[target] = self.pairs[target], self.pairs[index] - self.refresh(target) - self.pairsList.SetFocus() + def __init__(self, parent, pairs, sourceLangs, targetLangs): + # Translators: title of the dialog managing the language pairs. + super().__init__(parent, title=_("Language pairs")) + self.pairs = list(pairs) + self.sourceLangs = sourceLangs + self.targetLangs = targetLangs + + mainSizer = wx.BoxSizer(wx.VERTICAL) + helper = gui.guiHelper.BoxSizerHelper(self, orientation=wx.VERTICAL) + + # Translators: label of the list of language pairs, in the language pairs dialog. + listLabel = _( + "Language &pairs, activated by the keys 1 to 0 of the Instant Translate layer:" + ) + self.pairsList = helper.addLabeledControl( + listLabel, wx.ListBox, choices=[], style=wx.LB_SINGLE + ) + self.pairsList.Bind(wx.EVT_LISTBOX, self.onSelect) + self.pairsList.Bind(wx.EVT_LISTBOX_DCLICK, self.onEdit) + + buttonHelper = gui.guiHelper.ButtonHelper(wx.HORIZONTAL) + # Translators: a button of the language pairs dialog. + self.addBtn = buttonHelper.addButton(self, label=_("&Add")) + # Translators: a button of the language pairs dialog. + self.editBtn = buttonHelper.addButton(self, label=_("&Edit")) + # Translators: a button of the language pairs dialog. + self.removeBtn = buttonHelper.addButton(self, label=_("&Remove")) + # Translators: a button of the language pairs dialog, moving a pair one slot up. + self.upBtn = buttonHelper.addButton(self, label=_("Move &up")) + # Translators: a button of the language pairs dialog, moving a pair one slot down. + self.downBtn = buttonHelper.addButton(self, label=_("Move &down")) + self.addBtn.Bind(wx.EVT_BUTTON, self.onAdd) + self.editBtn.Bind(wx.EVT_BUTTON, self.onEdit) + self.removeBtn.Bind(wx.EVT_BUTTON, self.onRemove) + self.upBtn.Bind(wx.EVT_BUTTON, self.onMoveUp) + self.downBtn.Bind(wx.EVT_BUTTON, self.onMoveDown) + helper.addItem(buttonHelper) + + helper.addDialogDismissButtons(self.CreateButtonSizer(wx.OK | wx.CANCEL)) + mainSizer.Add( + helper.sizer, border=gui.guiHelper.BORDER_FOR_DIALOGS, flag=wx.ALL + ) + mainSizer.Fit(self) + self.SetSizer(mainSizer) + self.refresh(0 if self.pairs else wx.NOT_FOUND) + self.CentreOnScreen() + self.pairsList.SetFocus() + + def refresh(self, selection): + self.pairsList.Set( + [slotLabel(index, pair) for index, pair in enumerate(self.pairs)] + ) + if self.pairs: + self.pairsList.SetSelection(max(0, min(selection, len(self.pairs) - 1))) + else: + self.pairsList.SetSelection(wx.NOT_FOUND) + self.updateButtons() + + def updateButtons(self): + index = self.pairsList.GetSelection() + hasSelection = index != wx.NOT_FOUND + self.addBtn.Enable(len(self.pairs) < MAX_PAIRS) + self.editBtn.Enable(hasSelection) + self.removeBtn.Enable(hasSelection) + self.upBtn.Enable(hasSelection and index > 0) + self.downBtn.Enable(hasSelection and index < len(self.pairs) - 1) + + def onSelect(self, evt): + self.updateButtons() + + def editPair(self, pair=None): + dialog = LanguagePairDialog(self, self.sourceLangs, self.targetLangs, pair) + try: + return dialog.pair if dialog.ShowModal() == wx.ID_OK else None + finally: + dialog.Destroy() + + def onAdd(self, evt): + if len(self.pairs) >= MAX_PAIRS: + return + pair = self.editPair() + if pair is not None: + self.pairs.append(pair) + self.refresh(len(self.pairs) - 1) + self.pairsList.SetFocus() + + def onEdit(self, evt): + index = self.pairsList.GetSelection() + if index == wx.NOT_FOUND: + return + pair = self.editPair(self.pairs[index]) + if pair is not None: + self.pairs[index] = pair + self.refresh(index) + self.pairsList.SetFocus() + + def onRemove(self, evt): + index = self.pairsList.GetSelection() + if index == wx.NOT_FOUND: + return + del self.pairs[index] + self.refresh(index) + self.pairsList.SetFocus() + + def onMoveUp(self, evt): + self.move(-1) + + def onMoveDown(self, evt): + self.move(1) + + def move(self, offset): + index = self.pairsList.GetSelection() + target = index + offset + if index == wx.NOT_FOUND or not 0 <= target < len(self.pairs): + return + self.pairs[index], self.pairs[target] = self.pairs[target], self.pairs[index] + self.refresh(target) + self.pairsList.SetFocus() class LanguagePairDialog(wx.Dialog): - def __init__(self, parent, sourceLangs, targetLangs, pair=None): - # Translators: title of the dialog adding or editing one language pair. - super().__init__(parent, title=_("Language pair")) - self.sourceLangs = sourceLangs - self.targetLangs = targetLangs - - mainSizer = wx.BoxSizer(wx.VERTICAL) - helper = gui.guiHelper.BoxSizerHelper(self, orientation=wx.VERTICAL) - # Translators: A setting in addon settings dialog. - self._fromChoice = helper.addLabeledControl(_("Source language:"), wx.Choice, choices=sourceChoices(sourceLangs)) - # Translators: A setting in addon settings dialog. - self._intoChoice = helper.addLabeledControl(_("Target language:"), wx.Choice, choices=list(targetLangs)) - helper.addDialogDismissButtons(self.CreateButtonSizer(wx.OK | wx.CANCEL)) - mainSizer.Add(helper.sizer, border=gui.guiHelper.BORDER_FOR_DIALOGS, flag=wx.ALL) - mainSizer.Fit(self) - self.SetSizer(mainSizer) - - self._fromChoice.Select(max(0, self._fromChoice.FindString(getName(sourceLangs, pair.langFrom))) if pair else 0) - self._intoChoice.Select(max(0, self._intoChoice.FindString(getName(targetLangs, pair.langTo))) if pair else 0) - self.CentreOnScreen() - self._fromChoice.SetFocus() - - @property - def pair(self): - return LanguagePair( - self.sourceLangs[self._fromChoice.GetStringSelection()], - self.targetLangs[self._intoChoice.GetStringSelection()], - ) + def __init__(self, parent, sourceLangs, targetLangs, pair=None): + # Translators: title of the dialog adding or editing one language pair. + super().__init__(parent, title=_("Language pair")) + self.sourceLangs = sourceLangs + self.targetLangs = targetLangs + + mainSizer = wx.BoxSizer(wx.VERTICAL) + helper = gui.guiHelper.BoxSizerHelper(self, orientation=wx.VERTICAL) + # Translators: A setting in addon settings dialog. + self._fromChoice = helper.addLabeledControl( + _("Source language:"), wx.Choice, choices=sourceChoices(sourceLangs) + ) + # Translators: A setting in addon settings dialog. + self._intoChoice = helper.addLabeledControl( + _("Target language:"), wx.Choice, choices=list(targetLangs) + ) + helper.addDialogDismissButtons(self.CreateButtonSizer(wx.OK | wx.CANCEL)) + mainSizer.Add( + helper.sizer, border=gui.guiHelper.BORDER_FOR_DIALOGS, flag=wx.ALL + ) + mainSizer.Fit(self) + self.SetSizer(mainSizer) + + self._fromChoice.Select( + max(0, self._fromChoice.FindString(getName(sourceLangs, pair.langFrom))) + if pair + else 0 + ) + self._intoChoice.Select( + max(0, self._intoChoice.FindString(getName(targetLangs, pair.langTo))) + if pair + else 0 + ) + self.CentreOnScreen() + self._fromChoice.SetFocus() + + @property + def pair(self): + return LanguagePair( + self.sourceLangs[self._fromChoice.GetStringSelection()], + self.targetLangs[self._intoChoice.GetStringSelection()], + ) diff --git a/addon/globalPlugins/instantTranslate/langslist.py b/addon/globalPlugins/instantTranslate/langslist.py index 31e507d..5299d97 100644 --- a/addon/globalPlugins/instantTranslate/langslist.py +++ b/addon/globalPlugins/instantTranslate/langslist.py @@ -1,10 +1,10 @@ -#langslist.py +# langslist.py # Copyright (C) 2012-2024 Aleksey Sadovoy AKA Lex , -#ruslan , -#Beka Gozalishvili -#other nvda contributors -#This file is covered by the GNU General Public License. -#See the file COPYING for more details. +# ruslan , +# Beka Gozalishvili +# other nvda contributors +# This file is covered by the GNU General Public License. +# See the file COPYING for more details. from locale import strxfrm @@ -16,239 +16,242 @@ addonHandler.initTranslation() + def g(code, short=False): - """Return a description for the language code passed as parameter. The first found code is returned. - The check order is the following: - - the code in the forced codes list, i.e. codes for which NVDA/Windows do not return a satisfactory description - - the code is in NVDA/Windows language description - - the code in the list of needed codes, i.e. codes not available in some versions of Windows - If all these checks fail, return the code. - If short is True, returns a more compact description for the "auto" special code. - """ - if short and code == "auto": - # Translators: A short description for "Automatically detect language" language choice, reported when - # the user requests or swaps the current configuration. - return _("Automatic") - if code in forced_codes: - return forced_codes[code] - res = getLanguageDescription(code) - if res is not None: return res - if code in needed_codes: - return needed_codes[code] - return code + """Return a description for the language code passed as parameter. The first found code is returned. + The check order is the following: + - the code in the forced codes list, i.e. codes for which NVDA/Windows do not return a satisfactory description + - the code is in NVDA/Windows language description + - the code in the list of needed codes, i.e. codes not available in some versions of Windows + If all these checks fail, return the code. + If short is True, returns a more compact description for the "auto" special code. + """ + if short and code == "auto": + # Translators: A short description for "Automatically detect language" language choice, reported when + # the user requests or swaps the current configuration. + return _("Automatic") + if code in forced_codes: + return forced_codes[code] + res = getLanguageDescription(code) + if res is not None: + return res + if code in needed_codes: + return needed_codes[code] + return code + forced_codes = { - # Translators: The name of a language supported by this add-on. - "ckb": _("Kurdish (Sorani)"), + # Translators: The name of a language supported by this add-on. + "ckb": _("Kurdish (Sorani)"), } needed_codes = { - # Translators: An option to automatically detect source language for translation. - "auto":_("Automatically detect language"), - # Translators: The name of a language supported by this add-on. - "ak": _("Twi (Akan)"), # Missing, tested on Windows 10 22H2 - # Translators: The name of a language supported by this add-on. - "ay": _("Aymara"), # Missing, tested on Windows 10 22H2 - # Translators: The name of a language supported by this add-on. - "bho": _("Bhojpuri"), # Missing, tested on Windows 10 22H2 - # Translators: The name of a language supported by this add-on. - "bm":_("Bambara"), # Missing, tested on Windows 10 22H2 - # Translators: The name of a language supported by this add-on. - "ceb":_("Cebuano"), - # Translators: The name of a language supported by this add-on. - "doi": _("Dogri"), # Missing, tested on Windows 10 22H2 - # Translators: The name of a language supported by this add-on. - "ee": _("Ewe"), # Missing, tested on Windows 10 22H2 - # Translators: The name of a language supported by this add-on. - "eo":_("Esperanto"), - # Translators: The name of a language supported by this add-on. - "gom": _("Konkani"), # Missing, tested on Windows 10 22H2 - # Translators: The name of a language supported by this add-on. - "haw":_("Hawaiian"), - # Translators: The name of a language supported by this add-on. - "hmn":_("Hmong"), - # Translators: The name of a language supported by this add-on. - "ht":_("Creole Haiti"), - # Translators: The name of a language supported by this add-on. - "ilo": _("Ilocano"), # Missing, tested on Windows 10 22H2 - # Translators: The name of a language supported by this add-on. - "jv":_("Javanese"), - # Translators: The name of a language supported by this add-on. - "kri": _("Krio"), # Missing, tested on Windows 10 22H2 - # Translators: The name of a language supported by this add-on. - "ku":_("Kurdish"), - # Translators: The name of a language supported by this add-on. - "la":_("Latin"), - # Translators: The name of a language supported by this add-on. - "lg": _("Luganda"), # Missing, tested on Windows 10 22H2 - # Translators: The name of a language supported by this add-on. - "ln": _("Lingala"), # Missing, tested on Windows 10 22H2 - # Translators: The name of a language supported by this add-on. - "lus": _("Mizo"), # Missing, tested on Windows 10 22H2 - # Translators: The name of a language supported by this add-on. - "mai": _("Maithili"), # Missing, tested on Windows 10 22H2 - # Translators: The name of a language supported by this add-on. - "mg":_("Malagasy"), - # Translators: The name of a language supported by this add-on. - "mni-Mtei": _("Meiteilon (Manipuri)"), # Missing, tested on Windows 10 22H2 - # Translators: The name of a language supported by this add-on. - "my":_("Myanmar (Burmese)"), - # Translators: The name of a language supported by this add-on. - "ny":_("Chichewa"), - # Translators: The name of a language supported by this add-on. - "sd":_("Sindhi"), - # Translators: The name of a language supported by this add-on. - "sm":_("Samoan"), - # Translators: The name of a language supported by this add-on. - "sn":_("Shona"), - # Translators: The name of a language supported by this add-on. - "so":_("Somali"), - # Translators: The name of a language supported by this add-on. - "st":_("Sesotho"), - # Translators: The name of a language supported by this add-on. - "su":_("Sundanese"), - # Translators: The name of a language supported by this add-on. - "tl":_("Tagalog"), - # Translators: The name of a language supported by this add-on. - "yi":_("Yiddish"), + # Translators: An option to automatically detect source language for translation. + "auto": _("Automatically detect language"), + # Translators: The name of a language supported by this add-on. + "ak": _("Twi (Akan)"), # Missing, tested on Windows 10 22H2 + # Translators: The name of a language supported by this add-on. + "ay": _("Aymara"), # Missing, tested on Windows 10 22H2 + # Translators: The name of a language supported by this add-on. + "bho": _("Bhojpuri"), # Missing, tested on Windows 10 22H2 + # Translators: The name of a language supported by this add-on. + "bm": _("Bambara"), # Missing, tested on Windows 10 22H2 + # Translators: The name of a language supported by this add-on. + "ceb": _("Cebuano"), + # Translators: The name of a language supported by this add-on. + "doi": _("Dogri"), # Missing, tested on Windows 10 22H2 + # Translators: The name of a language supported by this add-on. + "ee": _("Ewe"), # Missing, tested on Windows 10 22H2 + # Translators: The name of a language supported by this add-on. + "eo": _("Esperanto"), + # Translators: The name of a language supported by this add-on. + "gom": _("Konkani"), # Missing, tested on Windows 10 22H2 + # Translators: The name of a language supported by this add-on. + "haw": _("Hawaiian"), + # Translators: The name of a language supported by this add-on. + "hmn": _("Hmong"), + # Translators: The name of a language supported by this add-on. + "ht": _("Creole Haiti"), + # Translators: The name of a language supported by this add-on. + "ilo": _("Ilocano"), # Missing, tested on Windows 10 22H2 + # Translators: The name of a language supported by this add-on. + "jv": _("Javanese"), + # Translators: The name of a language supported by this add-on. + "kri": _("Krio"), # Missing, tested on Windows 10 22H2 + # Translators: The name of a language supported by this add-on. + "ku": _("Kurdish"), + # Translators: The name of a language supported by this add-on. + "la": _("Latin"), + # Translators: The name of a language supported by this add-on. + "lg": _("Luganda"), # Missing, tested on Windows 10 22H2 + # Translators: The name of a language supported by this add-on. + "ln": _("Lingala"), # Missing, tested on Windows 10 22H2 + # Translators: The name of a language supported by this add-on. + "lus": _("Mizo"), # Missing, tested on Windows 10 22H2 + # Translators: The name of a language supported by this add-on. + "mai": _("Maithili"), # Missing, tested on Windows 10 22H2 + # Translators: The name of a language supported by this add-on. + "mg": _("Malagasy"), + # Translators: The name of a language supported by this add-on. + "mni-Mtei": _("Meiteilon (Manipuri)"), # Missing, tested on Windows 10 22H2 + # Translators: The name of a language supported by this add-on. + "my": _("Myanmar (Burmese)"), + # Translators: The name of a language supported by this add-on. + "ny": _("Chichewa"), + # Translators: The name of a language supported by this add-on. + "sd": _("Sindhi"), + # Translators: The name of a language supported by this add-on. + "sm": _("Samoan"), + # Translators: The name of a language supported by this add-on. + "sn": _("Shona"), + # Translators: The name of a language supported by this add-on. + "so": _("Somali"), + # Translators: The name of a language supported by this add-on. + "st": _("Sesotho"), + # Translators: The name of a language supported by this add-on. + "su": _("Sundanese"), + # Translators: The name of a language supported by this add-on. + "tl": _("Tagalog"), + # Translators: The name of a language supported by this add-on. + "yi": _("Yiddish"), } fallback_codes = [ - "auto", - "af", - "ak", - "am", - "ar", - "as", - "ay", - "az", - "be", - "bg", - "bho", - "bm", - "bn", - "bs", - "ca", - "ceb", - "ckb", - "co", - "cs", - "cy", - "da", - "de", - "doi", - "dv", - "ee", - "el", - "en", - "eo", - "es", - "et", - "eu", - "fa", - "fi", - "fil", - "fr", - "fy", - "ga", - "gd", - "gl", - "gn", - "gom", - "gu", - "ha", - "haw", - "he", - "hi", - "hmn", - "hr", - "ht", - "hu", - "hy", - "id", - "ig", - "ilo", - "is", - "it", - "ja", - "jv", - "ka", - "kk", - "km", - "kn", - "ko", - "kri", - "ku", - "ky", - "la", - "lb", - "lg", - "ln", - "lo", - "lt", - "lus", - "lv", - "mai", - "mg", - "mi", - "mk", - "ml", - "mn", - "mni-Mtei", - "mr", - "ms", - "mt", - "my", - "ne", - "nl", - "no", - "nso", - "ny", - "om", - "or", - "pa", - "pl", - "ps", - "pt", - "qu", - "ro", - "ru", - "rw", - "sa", - "sd", - "si", - "sk", - "sl", - "sm", - "sn", - "so", - "sq", - "sr", - "st", - "su", - "sv", - "sw", - "ta", - "te", - "tg", - "th", - "ti", - "tk", - "tl", - "tr", - "ts", - "tt", - "ug", - "uk", - "ur", - "uz", - "vi", - "xh", - "yi", - "yo", - "zh-CN", - "zh-TW", - "zu", + "auto", + "af", + "ak", + "am", + "ar", + "as", + "ay", + "az", + "be", + "bg", + "bho", + "bm", + "bn", + "bs", + "ca", + "ceb", + "ckb", + "co", + "cs", + "cy", + "da", + "de", + "doi", + "dv", + "ee", + "el", + "en", + "eo", + "es", + "et", + "eu", + "fa", + "fi", + "fil", + "fr", + "fy", + "ga", + "gd", + "gl", + "gn", + "gom", + "gu", + "ha", + "haw", + "he", + "hi", + "hmn", + "hr", + "ht", + "hu", + "hy", + "id", + "ig", + "ilo", + "is", + "it", + "ja", + "jv", + "ka", + "kk", + "km", + "kn", + "ko", + "kri", + "ku", + "ky", + "la", + "lb", + "lg", + "ln", + "lo", + "lt", + "lus", + "lv", + "mai", + "mg", + "mi", + "mk", + "ml", + "mn", + "mni-Mtei", + "mr", + "ms", + "mt", + "my", + "ne", + "nl", + "no", + "nso", + "ny", + "om", + "or", + "pa", + "pl", + "ps", + "pt", + "qu", + "ro", + "ru", + "rw", + "sa", + "sd", + "si", + "sk", + "sl", + "sm", + "sn", + "so", + "sq", + "sr", + "st", + "su", + "sv", + "sw", + "ta", + "te", + "tg", + "th", + "ti", + "tk", + "tl", + "tr", + "ts", + "tt", + "ug", + "uk", + "ur", + "uz", + "vi", + "xh", + "yi", + "yo", + "zh-CN", + "zh-TW", + "zu", ] SOURCE_ONLY_CODES = frozenset(["auto"]) @@ -256,39 +259,39 @@ def g(code, short=False): def getLanguages(kind, cache=None): - if kind not in ("source", "target"): - raise ValueError('kind has to be "source" or "target", got %r' % (kind,)) - languages = cache.get() if cache is not None else None - codes = languages.get(kind) if languages else None - if not codes: - excluded = TARGET_ONLY_CODES if kind == "source" else SOURCE_ONLY_CODES - codes = {code: None for code in fallback_codes if code not in excluded} - return _byName(codes) + if kind not in ("source", "target"): + raise ValueError('kind has to be "source" or "target", got %r' % (kind,)) + languages = cache.get() if cache is not None else None + codes = languages.get(kind) if languages else None + if not codes: + excluded = TARGET_ONLY_CODES if kind == "source" else SOURCE_ONLY_CODES + codes = {code: None for code in fallback_codes if code not in excluded} + return _byName(codes) def _byName(codes): - byName = {} - for code, reportedName in codes.items(): - name = g(code) - if name == code and reportedName: - name = reportedName - if name in byName: - log.error( - 'Unable to add "%s" (code "%s"): this language name already exists for code "%s".' - % (name, code, byName[name]) - ) - continue - byName[name] = code - return dict(sorted(byName.items(), key=lambda item: strxfrm(item[0]))) + byName = {} + for code, reportedName in codes.items(): + name = g(code) + if name == code and reportedName: + name = reportedName + if name in byName: + log.error( + 'Unable to add "%s" (code "%s"): this language name already exists for code "%s".' + % (name, code, byName[name]) + ) + continue + byName[name] = code + return dict(sorted(byName.items(), key=lambda item: strxfrm(item[0]))) def getLanguageName(code, short=False): - name = g(code, short=short) - if name != code: - return name - languages = languageCache.get(refresh=False) or {} - for kind in ("target", "source"): - reportedName = (languages.get(kind) or {}).get(code) - if reportedName: - return reportedName - return code + name = g(code, short=short) + if name != code: + return name + languages = languageCache.get(refresh=False) or {} + for kind in ("target", "source"): + reportedName = (languages.get(kind) or {}).get(code) + if reportedName: + return reportedName + return code diff --git a/addon/globalPlugins/instantTranslate/languagePairs.py b/addon/globalPlugins/instantTranslate/languagePairs.py index b74f493..28e6737 100644 --- a/addon/globalPlugins/instantTranslate/languagePairs.py +++ b/addon/globalPlugins/instantTranslate/languagePairs.py @@ -17,43 +17,44 @@ class LanguagePair(namedtuple("LanguagePair", ("langFrom", "langTo"))): + @classmethod + def parse(cls, value): + langFrom, separator, langTo = value.partition(SEPARATOR) + if not separator or not langFrom or not langTo: + return None + return cls(langFrom, langTo) - @classmethod - def parse(cls, value): - langFrom, separator, langTo = value.partition(SEPARATOR) - if not separator or not langFrom or not langTo: - return None - return cls(langFrom, langTo) + def format(self): + return "%s%s%s" % (self.langFrom, SEPARATOR, self.langTo) - def format(self): - return "%s%s%s" % (self.langFrom, SEPARATOR, self.langTo) - - @property - def label(self): - # Translators: a language pair, listed in the language pairs dialog. - return _("{lang1} to {lang2}").format( - lang1=getLanguageName(self.langFrom, short=True), - lang2=getLanguageName(self.langTo, short=True), - ) + @property + def label(self): + # Translators: a language pair, listed in the language pairs dialog. + return _("{lang1} to {lang2}").format( + lang1=getLanguageName(self.langFrom, short=True), + lang2=getLanguageName(self.langTo, short=True), + ) def parsePairs(values): - pairs = [] - for value in values or (): - pair = LanguagePair.parse(value) - if pair is None: - log.warning("Instant translate: ignoring the malformed language pair %r" % (value,)) - continue - pairs.append(pair) - if len(pairs) == MAX_PAIRS: - break - return pairs + pairs = [] + for value in values or (): + pair = LanguagePair.parse(value) + if pair is None: + log.warning( + "Instant translate: ignoring the malformed language pair %r" % (value,) + ) + continue + pairs.append(pair) + if len(pairs) == MAX_PAIRS: + break + return pairs def formatPairs(pairs): - return [pair.format() for pair in pairs[:MAX_PAIRS]] + return [pair.format() for pair in pairs[:MAX_PAIRS]] def slotLabel(index, pair): - # Translators: an entry of the language pairs list. {key} is the key of the layer activating the - return _("{key}: {pair}").format(key=PAIR_KEYS[index], pair=pair.label) + # Translators: an entry of the language pairs list. {key} is the key of the layer activating the + return _("{key}: {pair}").format(key=PAIR_KEYS[index], pair=pair.label) diff --git a/addon/globalPlugins/instantTranslate/providers.py b/addon/globalPlugins/instantTranslate/providers.py index 33c0583..8764f12 100644 --- a/addon/globalPlugins/instantTranslate/providers.py +++ b/addon/globalPlugins/instantTranslate/providers.py @@ -14,46 +14,48 @@ def discoverProviders(): - providers = {} - for _finder, moduleName, _isPackage in pkgutil.iter_modules(translators.__path__): - if moduleName == BASE_MODULE: - continue - try: - module = importlib.import_module(".%s" % moduleName, translators.__name__) - except Exception: - log.exception("Instant translate: cannot load the translators of %s" % moduleName) - continue - for member in vars(module).values(): - if not isinstance(member, type) or not issubclass(member, BaseTranslator): - continue - if member is BaseTranslator: - continue - providers[member.providerId] = member - return dict(sorted(providers.items())) + providers = {} + for _finder, moduleName, _isPackage in pkgutil.iter_modules(translators.__path__): + if moduleName == BASE_MODULE: + continue + try: + module = importlib.import_module(".%s" % moduleName, translators.__name__) + except Exception: + log.exception( + "Instant translate: cannot load the translators of %s" % moduleName + ) + continue + for member in vars(module).values(): + if not isinstance(member, type) or not issubclass(member, BaseTranslator): + continue + if member is BaseTranslator: + continue + providers[member.providerId] = member + return dict(sorted(providers.items())) DEFAULT_PROVIDER_ID = "google" PROVIDERS = discoverProviders() if not PROVIDERS: - log.error("Instant translate: no translation service was found") + log.error("Instant translate: no translation service was found") DEFAULT_PROVIDER = ( - DEFAULT_PROVIDER_ID - if DEFAULT_PROVIDER_ID in PROVIDERS - else next(iter(PROVIDERS), BaseTranslator.providerId) + DEFAULT_PROVIDER_ID + if DEFAULT_PROVIDER_ID in PROVIDERS + else next(iter(PROVIDERS), BaseTranslator.providerId) ) def getProvider(providerId): - return PROVIDERS.get(providerId) or PROVIDERS.get(DEFAULT_PROVIDER) + return PROVIDERS.get(providerId) or PROVIDERS.get(DEFAULT_PROVIDER) def nextProvider(providerId): - providerIds = list(PROVIDERS) - if not providerIds: - return DEFAULT_PROVIDER - try: - index = providerIds.index(providerId) - except ValueError: - return DEFAULT_PROVIDER - return providerIds[(index + 1) % len(providerIds)] + providerIds = list(PROVIDERS) + if not providerIds: + return DEFAULT_PROVIDER + try: + index = providerIds.index(providerId) + except ValueError: + return DEFAULT_PROVIDER + return providerIds[(index + 1) % len(providerIds)] diff --git a/addon/globalPlugins/instantTranslate/speechOnDemand.py b/addon/globalPlugins/instantTranslate/speechOnDemand.py index c603f39..44a0a5a 100644 --- a/addon/globalPlugins/instantTranslate/speechOnDemand.py +++ b/addon/globalPlugins/instantTranslate/speechOnDemand.py @@ -13,51 +13,55 @@ def isSpeechOnDemandFeatureAvailable(): - """Indicates if the speech on-demand feature is available in the current version of NVDA. - """ + """Indicates if the speech on-demand feature is available in the current version of NVDA.""" - try: - # NVDA >= 2024.1 - speech.SpeechMode.onDemand - return True - except AttributeError: - # NVDA <= 2023.3 - return False + try: + # NVDA >= 2024.1 + speech.SpeechMode.onDemand + return True + except AttributeError: + # NVDA <= 2023.3 + return False def getSpeechOnDemandParameter(): - """Returns a dictionary containing the speakOnDemand parameter and its True value for NVDA versions where - this feature exists; returns an empty dictionary otherwise. - Use `**getSpeechOnDemandParameter()` in script decorator's parameters to define `speakOnDemand=True` only - for NVDA versions where this feature is supported. - """ + """Returns a dictionary containing the speakOnDemand parameter and its True value for NVDA versions where + this feature exists; returns an empty dictionary otherwise. + Use `**getSpeechOnDemandParameter()` in script decorator's parameters to define `speakOnDemand=True` only + for NVDA versions where this feature is supported. + """ - if isSpeechOnDemandFeatureAvailable(): - # Define on-demand parameter only if the feature is available. - return {'speakOnDemand': True} - else: - return {} + if isSpeechOnDemandFeatureAvailable(): + # Define on-demand parameter only if the feature is available. + return {"speakOnDemand": True} + else: + return {} def executeWithSpeakOnDemand(f, *args, **kwargs): - """Allows to execute a function forcing the on-demand mode for its execution. - This may be useful for a functions that is scheduled by an on-demand script - to be run after the script execution has finished, e.g. using `core.callLater`. - This function should only be called from the main thread. - - Parameters: - f: function to execute. - args: unnamed arguments to pass to the function. - kwargs: keyword arguments to pass to the function. - """ - - if threading.get_ident() != core.mainThreadId: - raise RuntimeError('This function should only be executed from the main thread.') - - if not isSpeechOnDemandFeatureAvailable() or speech.getState().speechMode != speech.SpeechMode.onDemand: - return f(*args, **kwargs) - try: - speech.setSpeechMode(speech.SpeechMode.talk) - return f(*args, **kwargs) - finally: - speech.setSpeechMode(speech.SpeechMode.onDemand) + """Allows to execute a function forcing the on-demand mode for its execution. + This may be useful for a functions that is scheduled by an on-demand script + to be run after the script execution has finished, e.g. using `core.callLater`. + This function should only be called from the main thread. + + Parameters: + f: function to execute. + args: unnamed arguments to pass to the function. + kwargs: keyword arguments to pass to the function. + """ + + if threading.get_ident() != core.mainThreadId: + raise RuntimeError( + "This function should only be executed from the main thread." + ) + + if ( + not isSpeechOnDemandFeatureAvailable() + or speech.getState().speechMode != speech.SpeechMode.onDemand + ): + return f(*args, **kwargs) + try: + speech.setSpeechMode(speech.SpeechMode.talk) + return f(*args, **kwargs) + finally: + speech.setSpeechMode(speech.SpeechMode.onDemand) diff --git a/addon/globalPlugins/instantTranslate/translators/base.py b/addon/globalPlugins/instantTranslate/translators/base.py index fe9b588..75a2519 100644 --- a/addon/globalPlugins/instantTranslate/translators/base.py +++ b/addon/globalPlugins/instantTranslate/translators/base.py @@ -26,276 +26,299 @@ SPLIT_PATTERN = re.compile("|".join((ARABIC_BREAKS, CHINESE_BREAKS, LATIN_BREAKS))) + def cachePath(fileName): - return os.path.join(os.path.dirname(os.path.abspath(__file__)), fileName) + return os.path.join(os.path.dirname(os.path.abspath(__file__)), fileName) def encodedLength(text): - return len(urlencode({"q": text})) - 2 + return len(urlencode({"q": text})) - 2 def splitChunks(text, chunkSize, measure=len): - sizes = [0] - for char in text: - sizes.append(sizes[-1] + measure(char)) - - def sizeOf(start, end): - return sizes[end] - sizes[start] - - def emit(start, end): - while start < end: - stop = start + 1 - while stop < end and sizeOf(start, stop + 1) <= chunkSize: - stop += 1 - yield text[start:stop] - start = stop - - pos = potentialPos = 0 - for splitMark in SPLIT_PATTERN.finditer(text): - if sizeOf(pos, splitMark.start() + 1) < chunkSize: - potentialPos = splitMark.start() - continue - yield from emit(pos, potentialPos + 1) - pos = potentialPos + 1 - potentialPos = splitMark.start() - yield from emit(pos, len(text)) + sizes = [0] + for char in text: + sizes.append(sizes[-1] + measure(char)) + + def sizeOf(start, end): + return sizes[end] - sizes[start] + + def emit(start, end): + while start < end: + stop = start + 1 + while stop < end and sizeOf(start, stop + 1) <= chunkSize: + stop += 1 + yield text[start:stop] + start = stop + + pos = potentialPos = 0 + for splitMark in SPLIT_PATTERN.finditer(text): + if sizeOf(pos, splitMark.start() + 1) < chunkSize: + potentialPos = splitMark.start() + continue + yield from emit(pos, potentialPos + 1) + pos = potentialPos + 1 + potentialPos = splitMark.start() + yield from emit(pos, len(text)) def normalizeLineEndings(text): - found = LINE_ENDING_PATTERN.search(text) - return LINE_ENDING_PATTERN.sub("\n", text), found.group() if found else "\n" + found = LINE_ENDING_PATTERN.search(text) + return LINE_ENDING_PATTERN.sub("\n", text), found.group() if found else "\n" def splitLineRuns(text): - return [part for part in LINE_RUN_PATTERN.split(text) if part] + return [part for part in LINE_RUN_PATTERN.split(text) if part] def splitEdges(text): - stripped = text.strip() - if not stripped: - return text, "", "" - return text[:len(text) - len(text.lstrip())], stripped, text[len(text.rstrip()):] + stripped = text.strip() + if not stripped: + return text, "", "" + return text[: len(text) - len(text.lstrip())], stripped, text[len(text.rstrip()) :] class LanguageCache: - def __init__(self, path, ttl, fetch, getContext=None): - self.path = path - self.ttl = ttl - self.fetch = fetch - self.getContext = getContext or (lambda: "") - self._lock = threading.RLock() - self._refreshing = False - self._languages = None - self._timestamp = 0 - self._context = None - self._load() - - def _load(self): - try: - with open(self.path, "r", encoding="utf-8") as cacheFile: - cached = json.load(cacheFile) - except FileNotFoundError: - return - except Exception: - log.warning("Instant translate: unreadable language cache %s" % self.path, exc_info=True) - return - self._languages = cached.get("languages") - self._timestamp = cached.get("timestamp", 0) - self._context = cached.get("context") - - def _save(self): - cached = { - "timestamp": self._timestamp, - "context": self._context, - "languages": self._languages, - } - try: - with open(self.path, "w", encoding="utf-8") as cacheFile: - json.dump(cached, cacheFile, ensure_ascii=False, indent="\t", sort_keys=True) - except Exception: - log.warning("Instant translate: cannot write language cache %s" % self.path, exc_info=True) - - @property - def isStale(self): - if self._languages is None: - return True - if self._context != self.getContext(): - return True - return abs(time() - self._timestamp) > self.ttl - - def get(self, refresh=True): - with self._lock: - if refresh and self.isStale: - self.refreshInBackground() - return self._languages - - def refresh(self): - try: - languages = self.fetch() - except Exception: - log.warning("Instant translate: cannot fetch the supported languages", exc_info=True) - return False - if not languages: - log.warning("Instant translate: the supported languages request returned nothing") - return False - with self._lock: - self._languages = languages - self._timestamp = time() - self._context = self.getContext() - self._save() - return True - - def refreshInBackground(self): - with self._lock: - if self._refreshing: - return - self._refreshing = True - def run(): - try: - self.refresh() - finally: - with self._lock: - self._refreshing = False - threading.Thread(target=run, daemon=True, name="InstantTranslateLanguages").start() + def __init__(self, path, ttl, fetch, getContext=None): + self.path = path + self.ttl = ttl + self.fetch = fetch + self.getContext = getContext or (lambda: "") + self._lock = threading.RLock() + self._refreshing = False + self._languages = None + self._timestamp = 0 + self._context = None + self._load() + + def _load(self): + try: + with open(self.path, "r", encoding="utf-8") as cacheFile: + cached = json.load(cacheFile) + except FileNotFoundError: + return + except Exception: + log.warning( + "Instant translate: unreadable language cache %s" % self.path, + exc_info=True, + ) + return + self._languages = cached.get("languages") + self._timestamp = cached.get("timestamp", 0) + self._context = cached.get("context") + + def _save(self): + cached = { + "timestamp": self._timestamp, + "context": self._context, + "languages": self._languages, + } + try: + with open(self.path, "w", encoding="utf-8") as cacheFile: + json.dump( + cached, cacheFile, ensure_ascii=False, indent="\t", sort_keys=True + ) + except Exception: + log.warning( + "Instant translate: cannot write language cache %s" % self.path, + exc_info=True, + ) + + @property + def isStale(self): + if self._languages is None: + return True + if self._context != self.getContext(): + return True + return abs(time() - self._timestamp) > self.ttl + + def get(self, refresh=True): + with self._lock: + if refresh and self.isStale: + self.refreshInBackground() + return self._languages + + def refresh(self): + try: + languages = self.fetch() + except Exception: + log.warning( + "Instant translate: cannot fetch the supported languages", exc_info=True + ) + return False + if not languages: + log.warning( + "Instant translate: the supported languages request returned nothing" + ) + return False + with self._lock: + self._languages = languages + self._timestamp = time() + self._context = self.getContext() + self._save() + return True + + def refreshInBackground(self): + with self._lock: + if self._refreshing: + return + self._refreshing = True + + def run(): + try: + self.refresh() + finally: + with self._lock: + self._refreshing = False + + threading.Thread( + target=run, daemon=True, name="InstantTranslateLanguages" + ).start() class BaseTranslator(threading.Thread): - providerId = "base" - providerName = "base" - headers = {} - languageCache = None - maxChunkSize = 12000 - measureChunk = staticmethod(encodedLength) - legacyCodes = {"iw": "he", "jw": "jv"} - timeout = DEFAULT_TIMEOUT - - def __init__( - self, - langFrom, - langTo, - text, - langSwap=None, - chunkSize=None, - onSuccess=None, - onError=None, - onProgress=None, - onFinished=None, - ): - super().__init__(name="InstantTranslate%s" % type(self).__name__, daemon=True) - if langFrom != "auto" and langSwap is not None: - raise ValueError( - "langSwap=%r is only meaningful with langFrom='auto', got langFrom=%r" - % (langSwap, langFrom) - ) - self.langFrom = langFrom - self.langTo = langTo - self.text, self.lineEnding = normalizeLineEndings(text) - self.langSwap = langSwap - self.chunkSize = chunkSize or self.maxChunkSize - self.onSuccess = onSuccess - self.onError = onError - self.onProgress = onProgress - self.onFinished = onFinished - self.chunks = [] - for part in splitLineRuns(self.text): - if part.startswith("\n"): - self.chunks.append(part) - else: - self.chunks.extend(splitChunks(part, self.chunkSize, self.measureChunk)) - self.completedChunks = 0 - self.translation = "" - self.detectedLanguage = "" - self.error = False - self._stopEvent = threading.Event() - self.session = Session(headers=self.headers, timeout=self.timeout) - - @property - def totalChunks(self): - return len(self.chunks) - - @property - def remainingChunks(self): - return self.totalChunks - self.completedChunks - - @property - def percentDone(self): - if not self.totalChunks: - return 100 - return int(round(self.completedChunks * 100.0 / self.totalChunks)) - - def __init_subclass__(cls, **kwargs): - super().__init_subclass__(**kwargs) - if "providerId" in cls.__dict__: - return - cls.providerId = cls.__name__.lower() - - def stop(self): - self._stopEvent.set() - try: - self.session.close() - except Exception: - log.debug("Instant translate: cannot abort the request of %s" % self.providerId, exc_info=True) - - @property - def shouldStop(self): - return self._stopEvent.is_set() - - def run(self): - try: - self._translateChunks() - finally: - self._reportOutcome() - - def _translateChunks(self): - isFirst = True - for index, chunk in enumerate(self.chunks): - if self.shouldStop: - return - leading, stripped, trailing = splitEdges(chunk) - if not stripped: - self.translation += chunk - self.completedChunks = index + 1 - self._report(self.onProgress) - continue - try: - translation, detected = self.translateChunk(stripped, self.langTo) - self.detectedLanguage = self.legacyCodes.get(detected, detected) - if isFirst and self.shouldSwap(): - self.langTo = self.langSwap - translation, detected = self.translateChunk(stripped, self.langTo) - isFirst = False - translation = leading + translation + trailing - except Exception: - if self.shouldStop: - return - log.exception("Instant translate: %s cannot translate %r" % (self.providerId, chunk)) - self.error = True - return - self.translation += translation - self.completedChunks = index + 1 - self._report(self.onProgress) - if self.lineEnding != "\n": - self.translation = self.translation.replace("\n", self.lineEnding) - - def _reportOutcome(self): - if not self.shouldStop: - self._report(self.onError if self.error else self.onSuccess) - self._report(self.onFinished) - - def _report(self, callback): - if callback is None: - return - try: - callback(self) - except Exception: - log.exception("Instant translate: a %s callback failed" % self.providerId) - - def shouldSwap(self): - return ( - self.langSwap is not None - and self.langFrom == "auto" - and self.detectedLanguage == self.langTo - ) - - def translateChunk(self, chunk, langTo): - raise NotImplementedError + providerId = "base" + providerName = "base" + headers = {} + languageCache = None + maxChunkSize = 12000 + measureChunk = staticmethod(encodedLength) + legacyCodes = {"iw": "he", "jw": "jv"} + timeout = DEFAULT_TIMEOUT + + def __init__( + self, + langFrom, + langTo, + text, + langSwap=None, + chunkSize=None, + onSuccess=None, + onError=None, + onProgress=None, + onFinished=None, + ): + super().__init__(name="InstantTranslate%s" % type(self).__name__, daemon=True) + if langFrom != "auto" and langSwap is not None: + raise ValueError( + "langSwap=%r is only meaningful with langFrom='auto', got langFrom=%r" + % (langSwap, langFrom) + ) + self.langFrom = langFrom + self.langTo = langTo + self.text, self.lineEnding = normalizeLineEndings(text) + self.langSwap = langSwap + self.chunkSize = chunkSize or self.maxChunkSize + self.onSuccess = onSuccess + self.onError = onError + self.onProgress = onProgress + self.onFinished = onFinished + self.chunks = [] + for part in splitLineRuns(self.text): + if part.startswith("\n"): + self.chunks.append(part) + else: + self.chunks.extend(splitChunks(part, self.chunkSize, self.measureChunk)) + self.completedChunks = 0 + self.translation = "" + self.detectedLanguage = "" + self.error = False + self._stopEvent = threading.Event() + self.session = Session(headers=self.headers, timeout=self.timeout) + + @property + def totalChunks(self): + return len(self.chunks) + + @property + def remainingChunks(self): + return self.totalChunks - self.completedChunks + + @property + def percentDone(self): + if not self.totalChunks: + return 100 + return int(round(self.completedChunks * 100.0 / self.totalChunks)) + + def __init_subclass__(cls, **kwargs): + super().__init_subclass__(**kwargs) + if "providerId" in cls.__dict__: + return + cls.providerId = cls.__name__.lower() + + def stop(self): + self._stopEvent.set() + try: + self.session.close() + except Exception: + log.debug( + "Instant translate: cannot abort the request of %s" % self.providerId, + exc_info=True, + ) + + @property + def shouldStop(self): + return self._stopEvent.is_set() + + def run(self): + try: + self._translateChunks() + finally: + self._reportOutcome() + + def _translateChunks(self): + isFirst = True + for index, chunk in enumerate(self.chunks): + if self.shouldStop: + return + leading, stripped, trailing = splitEdges(chunk) + if not stripped: + self.translation += chunk + self.completedChunks = index + 1 + self._report(self.onProgress) + continue + try: + translation, detected = self.translateChunk(stripped, self.langTo) + self.detectedLanguage = self.legacyCodes.get(detected, detected) + if isFirst and self.shouldSwap(): + self.langTo = self.langSwap + translation, detected = self.translateChunk(stripped, self.langTo) + isFirst = False + translation = leading + translation + trailing + except Exception: + if self.shouldStop: + return + log.exception( + "Instant translate: %s cannot translate %r" + % (self.providerId, chunk) + ) + self.error = True + return + self.translation += translation + self.completedChunks = index + 1 + self._report(self.onProgress) + if self.lineEnding != "\n": + self.translation = self.translation.replace("\n", self.lineEnding) + + def _reportOutcome(self): + if not self.shouldStop: + self._report(self.onError if self.error else self.onSuccess) + self._report(self.onFinished) + + def _report(self, callback): + if callback is None: + return + try: + callback(self) + except Exception: + log.exception("Instant translate: a %s callback failed" % self.providerId) + + def shouldSwap(self): + return ( + self.langSwap is not None + and self.langFrom == "auto" + and self.detectedLanguage == self.langTo + ) + + def translateChunk(self, chunk, langTo): + raise NotImplementedError diff --git a/addon/globalPlugins/instantTranslate/translators/deepl.py b/addon/globalPlugins/instantTranslate/translators/deepl.py index 6140145..76c111b 100644 --- a/addon/globalPlugins/instantTranslate/translators/deepl.py +++ b/addon/globalPlugins/instantTranslate/translators/deepl.py @@ -14,46 +14,52 @@ LANGUAGE_MODEL = "next-gen" USAGE_TYPE = "Translate" USER_AGENT = ( - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko)" - " Chrome/151.0.0.0 Safari/537.36 Edg/151.0.0.0" + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko)" + " Chrome/151.0.0.0 Safari/537.36 Edg/151.0.0.0" ) HEADERS = { - "Accept": "*/*", - "Content-Type": "application/json", - "Origin": "https://www.deepl.com", - "Referer": "https://www.deepl.com/", - "User-agent": USER_AGENT, + "Accept": "*/*", + "Content-Type": "application/json", + "Origin": "https://www.deepl.com", + "Referer": "https://www.deepl.com/", + "User-agent": USER_AGENT, } APP_INFORMATION = { - "instance_id": str(uuid4()), - "app_build": "Edge", - "os": "Windows", - "app_version": "any", - "os_version": "any", + "instance_id": str(uuid4()), + "app_build": "Edge", + "os": "Windows", + "app_version": "any", + "os_version": "any", } class Deepl(BaseTranslator): - # Translators: the name of a translation service, presented when switching between services. - providerName = _("DeepL") - headers = HEADERS - measureChunk = staticmethod(len) - maxChunkSize = 500 - - def translateChunk(self, chunk, langTo): - body = { - "text": [chunk], - "source_lang": self.langFrom, - "target_lang": langTo, - "language_model": LANGUAGE_MODEL, - "usage_type": USAGE_TYPE, - "app_information": APP_INFORMATION, - } - response = self.session.post(TRANSLATE_URL, json=body).json() - translations = response.get("translations") if isinstance(response, dict) else None - if not translations: - raise ValueError("%s returned no translation: %r" % (self.providerId, response)) - translation = translations[0] or {} - detected = (translation.get("detected_source_language") or self.langFrom).lower() - return translation.get("text") or "", detected + # Translators: the name of a translation service, presented when switching between services. + providerName = _("DeepL") + headers = HEADERS + measureChunk = staticmethod(len) + maxChunkSize = 500 + + def translateChunk(self, chunk, langTo): + body = { + "text": [chunk], + "source_lang": self.langFrom, + "target_lang": langTo, + "language_model": LANGUAGE_MODEL, + "usage_type": USAGE_TYPE, + "app_information": APP_INFORMATION, + } + response = self.session.post(TRANSLATE_URL, json=body).json() + translations = ( + response.get("translations") if isinstance(response, dict) else None + ) + if not translations: + raise ValueError( + "%s returned no translation: %r" % (self.providerId, response) + ) + translation = translations[0] or {} + detected = ( + translation.get("detected_source_language") or self.langFrom + ).lower() + return translation.get("text") or "", detected diff --git a/addon/globalPlugins/instantTranslate/translators/google.py b/addon/globalPlugins/instantTranslate/translators/google.py index 61ebc8e..2705655 100644 --- a/addon/globalPlugins/instantTranslate/translators/google.py +++ b/addon/globalPlugins/instantTranslate/translators/google.py @@ -21,61 +21,63 @@ class Google(BaseTranslator): - # Translators: the name of a translation service, presented when switching between services. - providerName = _("Google Translate") - headers = HEADERS - maxChunkSize = 12000 - - def translateChunk(self, chunk, langTo): - params = [ - ("params.client", CLIENT_NAME), - ("query.source_language", self.langFrom), - ("query.target_language", langTo), - ("query.display_language", displayLanguage()), - ("query.text", chunk), - ("key", API_KEY), - ] - params += [("data_types", dataType) for dataType in DATA_TYPES] - response = self.session.get(TRANSLATE_URL, params=params).json() - sentences = response[1] if len(response) > 1 else None - if sentences: - translation = "".join(sentence[0] for sentence in sentences if sentence and sentence[0]) - else: - translation = response[0] or "" - detected = response[5] if len(response) > 5 and response[5] else self.langFrom - return translation, detected + # Translators: the name of a translation service, presented when switching between services. + providerName = _("Google Translate") + headers = HEADERS + maxChunkSize = 12000 + + def translateChunk(self, chunk, langTo): + params = [ + ("params.client", CLIENT_NAME), + ("query.source_language", self.langFrom), + ("query.target_language", langTo), + ("query.display_language", displayLanguage()), + ("query.text", chunk), + ("key", API_KEY), + ] + params += [("data_types", dataType) for dataType in DATA_TYPES] + response = self.session.get(TRANSLATE_URL, params=params).json() + sentences = response[1] if len(response) > 1 else None + if sentences: + translation = "".join( + sentence[0] for sentence in sentences if sentence and sentence[0] + ) + else: + translation = response[0] or "" + detected = response[5] if len(response) > 5 and response[5] else self.langFrom + return translation, detected def displayLanguage(): - return languageHandler.getLanguage().replace("_", "-") + return languageHandler.getLanguage().replace("_", "-") def fetchLanguages(): - params = [ - ("client", CLIENT_NAME), - ("display_language", displayLanguage()), - ("key", API_KEY), - ] - response = httpClient.get(LANGUAGES_URL, params=params, headers=HEADERS).json() - return { - "source": _asLanguageDict(response[0]), - "target": _asLanguageDict(response[1]), - } + params = [ + ("client", CLIENT_NAME), + ("display_language", displayLanguage()), + ("key", API_KEY), + ] + response = httpClient.get(LANGUAGES_URL, params=params, headers=HEADERS).json() + return { + "source": _asLanguageDict(response[0]), + "target": _asLanguageDict(response[1]), + } def _asLanguageDict(languages): - return { - BaseTranslator.legacyCodes.get(code, code): name - for code, name in languages - if code and name - } + return { + BaseTranslator.legacyCodes.get(code, code): name + for code, name in languages + if code and name + } languageCache = LanguageCache( - path=cachePath(LANGUAGES_FILE), - ttl=LANGUAGES_TTL, - fetch=fetchLanguages, - getContext=displayLanguage, + path=cachePath(LANGUAGES_FILE), + ttl=LANGUAGES_TTL, + fetch=fetchLanguages, + getContext=displayLanguage, ) Google.languageCache = languageCache diff --git a/addon/installTasks.py b/addon/installTasks.py index 9b5a45c..0eb40bb 100644 --- a/addon/installTasks.py +++ b/addon/installTasks.py @@ -2,17 +2,21 @@ import gui import os.path import sys +import wx addon = addonHandler.getCodeAddon() addonName = addon.name -addonDir = os.path.abspath(os.path.join(os.path.dirname(__file__), "globalPlugins", addonName)) +addonDir = os.path.abspath( + os.path.join(os.path.dirname(__file__), "globalPlugins", addonName) +) sys.path.append(addonDir) -from donate_dialog import requestDonations +from donate_dialog import requestDonations # noqa: E402 + sys.path.remove(sys.path[-1]) -import wx addonHandler.initTranslation() + def onInstall(): gui.mainFrame.prePopup() wx.CallAfter(requestDonations, gui.mainFrame)