diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..50617c0 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,14 @@ +version: 2 +updates: + + # Maintain dependencies for GitHub Actions + - package-ecosystem: "github-actions" + # Workflow files stored in the default location of `.github/workflows`. (You don't need to specify `/.github/workflows` for `directory`. You can use `directory: "/"`.) + directory: "/" + schedule: + interval: "weekly" + groups: + actions-minor: + update-types: + - minor + - patch diff --git a/.github/workflows/release-on-tag.yaml b/.github/workflows/release-on-tag.yaml index 4c93430..ee702c1 100644 --- a/.github/workflows/release-on-tag.yaml +++ b/.github/workflows/release-on-tag.yaml @@ -5,6 +5,8 @@ on: push: tags: ['*'] + pull_request: + branches: [ main, master ] workflow_dispatch: jobs: @@ -12,17 +14,16 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@v3 - - run: echo -e "scons\nmarkdown">requirements.txt + uses: actions/checkout@v4 - name: Set up Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: - python-version: 3.11 + python-version: 3.13 cache: 'pip' - name: Install dependencies run: | - pip install scons markdown sudo apt update + pip install scons markdown sudo apt install gettext - name: Set add-on version from tag @@ -36,14 +37,13 @@ jobs: f.write(text) f.truncate() shell: python - - name: Build add-on run: scons - name: load latest changes from changelog run: awk '/^# / && !flag {flag=1; next} /^# / && flag {exit} flag && NF' changelog.md > newChanges.md - name: Calculate sha256 run: sha256sum *.nvda-addon >> newChanges.md - - uses: actions/upload-artifact@v3 + - uses: actions/upload-artifact@v7 with: name: packaged_addon path: | @@ -56,12 +56,12 @@ jobs: needs: ["build"] steps: - name: download releases files - uses: actions/download-artifact@v3 + uses: actions/download-artifact@v4 - name: Display structure of downloaded files run: ls -R - name: Release - uses: softprops/action-gh-release@v1 + uses: softprops/action-gh-release@v2 with: body_path: packaged_addon/newChanges.md files: packaged_addon/*.nvda-addon diff --git a/.gitignore b/.gitignore index 172e42b..0be8af1 100644 --- a/.gitignore +++ b/.gitignore @@ -2,10 +2,10 @@ addon/doc/*.css addon/doc/en/ *_docHandler.py *.html -*.ini +manifest.ini *.mo +*.pot *.py[co] *.nvda-addon .sconsign.dblite -*.code-workspace -*.json \ No newline at end of file +/[0-9]*.[0-9]*.[0-9]*.json diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..854ca85 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,13 @@ +{ + // See https://go.microsoft.com/fwlink/?LinkId=827846 to learn about workspace recommendations. + // Extension identifier format: ${publisher}.${name}. Example: vscode.csharp + // List of extensions which should be recommended for users of this workspace. + "recommendations": [ + "ms-python.python", + "ms-python.vscode-pylance", + "redhat.vscode-yaml", + "charliermarsh.ruff" + ], + // List of extensions recommended by VS Code that should not be recommended for users of this workspace. + "unwantedRecommendations": [] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..8076b6f --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,16 @@ +{ + "editor.accessibilitySupport": "on", + "python.autoComplete.extraPaths": [ + "../../nvda/source", + "../../nvda/miscDeps/python" + ], + "files.insertFinalNewline": true, + "files.trimFinalNewlines": true, + "editor.insertSpaces": false, + "python.analysis.stubPath": "${workspaceFolder}/.vscode/typings", + "python.analysis.extraPaths": [ + "../../nvda/source", + "../../nvda/miscDeps/python" + ], + "python.defaultInterpreterPath": "${workspaceFolder}/../../.venv/scripts/python.exe" +} diff --git a/.vscode/typings/__builtins__.pyi b/.vscode/typings/__builtins__.pyi new file mode 100644 index 0000000..48cbde4 --- /dev/null +++ b/.vscode/typings/__builtins__.pyi @@ -0,0 +1,2 @@ +def _(msg: str) -> str: ... +def pgettext(context: str, message: str) -> str: ... diff --git a/beepKeyboard.code-workspace b/beepKeyboard.code-workspace deleted file mode 100644 index 29b84d1..0000000 --- a/beepKeyboard.code-workspace +++ /dev/null @@ -1,50 +0,0 @@ -{ - "folders": [ - { - "path": "." - } - ], - "settings": { - "editor.accessibilitySupport": "on", - "python.linting.enabled": true, - "python.linting.maxNumberOfProblems": 10000, - "python.linting.flake8Args": [ - "--config=..\\..\\nvda\\tests\\lint\\flake8.ini", - "--use-flake8-tabs=true" - ], - "python.linting.flake8Enabled": true, - "python.linting.pylintEnabled": false, - "python.autoComplete.extraPaths": [ - "addon", - "../../nvda/source", - "../../nvda/include/comtypes", - "../../nvda/include/configobj/src", - "../../nvda/include/pyserial", - "../../nvda/include/wxPython", - "../../nvda/miscDeps/python" - ], - "files.insertFinalNewline": true, - "files.trimFinalNewlines": true, - "editor.insertSpaces": false, - "python.testing.unittestArgs": [ - "-v", - "-s", - "tests.unit", - "-p", - "test_*.py" - ], - "python.testing.pytestEnabled": false, - "python.testing.nosetestsEnabled": false, - "python.testing.unittestEnabled": false, - "python.analysis.extraPaths": [ - "addon", - "../../nvda/source", - "../../nvda/include/comtypes", - "../../nvda/include/configobj/src", - "../../nvda/include/pyserial", - "../../nvda/include/wxPython", - "../../nvda/miscDeps/python" - ] - } - -} diff --git a/buildVars.py b/buildVars.py index 51f03b9..061afe8 100644 --- a/buildVars.py +++ b/buildVars.py @@ -1,64 +1,60 @@ -# -*- coding: UTF-8 -*- - -# Build customizations -# Change this file instead of sconstruct or manifest files, whenever possible. - -# Full getext (please don't change) -_ = lambda x : x - -# Add-on information variables -addon_info = { - # for previously unpublished addons, please follow the community guidelines at: - # https://bitbucket.org/nvdaaddonteam/todo/raw/master/guideLines.txt - # add-on Name, internal for nvda - "addon_name" : "beepKeyboard", - # Add-on summary, usually the user visible name of the addon. - # Translators: Summary for this add-on to be shown on installation and add-on information. - "addon_summary" : _("Beep keyboard"), - # Add-on description - # Translators: Long description to be shown for this add-on on add-on information from add-ons manager - "addon_description" : _("""This add-on beeps with some keyboard events."""), - # version - "addon_version" : "24.1.1", - # Author(s) - "addon_author" : u"David CM ", - # URL for the add-on documentation support - "addon_url" : "https://github.com/david-acm/NVDA-beepKeyboard", - # Documentation file name - "addon_docFileName" : "readme.html", - # Minimum NVDA version supported (e.g. "2018.3.0") - "addon_minimumNVDAVersion" : "2018.3.0", - # Last NVDA version supported/tested (e.g. "2018.4.0", ideally more recent than minimum version) - "addon_lastTestedNVDAVersion" : "2024.2", - # Add-on update channel (default is stable or None) - "addon_updateChannel" : None, +from site_scons.site_tools.NVDATool.utils import _ + +class Config: + VERSION = "26.5.1" + MIN_NVDA = "2018.3.0" + LAST_TESTED = "2026.1" + # Essential Metadata + ID = "beepKeyboard" + # Translators: Summary/title for this add-on + SUMMARY = _("Beep keyboard") + # Translators: Long description to be shown for this add-on on add-on information from add-on store + DESCRIPTION = _("""This add-on beeps with some keyboard events.""") + # Translators: what's new content for the add-on version to be shown in the add-on store + CHANGELOG = _("""Initial migration to the new template.""") + author="David CM " + URL = "https://github.com/david-acm/NVDA-beepKeyboard" + +from site_scons.site_tools.NVDATool.typings import AddonInfo, BrailleTables, SymbolDictionaries, SpeechDictionaries + + +addon_info = AddonInfo( + addon_name=Config.ID, + addon_summary=Config.SUMMARY, + addon_description=Config.DESCRIPTION, + addon_version=Config.VERSION, + addon_changelog=Config.CHANGELOG, + addon_author=Config.author, + addon_url=Config.URL, + addon_sourceURL=Config.URL, + addon_docFileName="readme.html", + addon_minimumNVDAVersion=Config.MIN_NVDA, + addon_lastTestedNVDAVersion=Config.LAST_TESTED, + # 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=None, # Add-on license such as GPL 2 - "addon_license": "GPL 2", + addon_license="GPL 2", # URL for the license document the ad-on is licensed under - "addon_licenseURL": "https://www.gnu.org/licenses/old-licenses/gpl-2.0.html", - # URL for the add-on repository where the source code can be found - "addon_sourceURL": "https://github.com/davidacm/beepKeyboard", -} + addon_licenseURL="https://www.gnu.org/licenses/old-licenses/gpl-2.0.html", +) -from os import path +pythonSources: list[str] = ["addon/globalPlugins/beepKeyboard/*.py"] -# Define the python files that are the sources of your add-on. -# You can use glob expressions here, they will be expanded. -pythonSources = [path.join("addon", "globalPlugins", "beepKeyboard", "*.py")] - -# Files that contain strings for translation. Usually your python sources -i18nSources = pythonSources + ["buildVars.py"] +i18nSources: list[str] = 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 = [] +# You can either list every file (using ""/") as a path separator, +# or use glob expressions. +excludedFiles: list[str] = [] + +baseLanguage: str = "en" + +markdownExtensions: list[str] = [] -# Base language for the NVDA add-on -baseLanguage = "en" +brailleTables: BrailleTables = {} -# 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 = [] +symbolDictionaries: SymbolDictionaries = {} +speechDictionaries: SpeechDictionaries = {} diff --git a/changelog.md b/changelog.md index 64730a1..ef2513a 100644 --- a/changelog.md +++ b/changelog.md @@ -1,3 +1,7 @@ +# version 26.5.1. + +Added support for NVDA 2026.1. + # version 24.1.1. * added support for NVDA 2024.2. diff --git a/manifest-translated.ini.tpl b/manifest-translated.ini.tpl index c06aa84..6df6d42 100644 --- a/manifest-translated.ini.tpl +++ b/manifest-translated.ini.tpl @@ -1,2 +1,3 @@ summary = "{addon_summary}" description = """{addon_description}""" +changelog = """{addon_changelog}""" diff --git a/manifest.ini.tpl b/manifest.ini.tpl index d44355d..2b7b0eb 100644 --- a/manifest.ini.tpl +++ b/manifest.ini.tpl @@ -4,6 +4,7 @@ description = """{addon_description}""" author = "{addon_author}" url = {addon_url} version = {addon_version} +changelog = """{addon_changelog}""" docFileName = {addon_docFileName} minimumNVDAVersion = {addon_minimumNVDAVersion} lastTestedNVDAVersion = {addon_lastTestedNVDAVersion} diff --git a/sconstruct b/sconstruct index dde28af..cdbad3f 100644 --- a/sconstruct +++ b/sconstruct @@ -1,90 +1,53 @@ -# NVDA add-on template SCONSTRUCT file -# Copyright (C) 2012-2023 Rui Batista, Noelia Martinez, Joseph Lee +# 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 zipfile import sys +from pathlib import Path +from collections.abc import Iterable +from typing import Final # While names imported below are available by default in every SConscript # Linters aren't aware about them. -# To avoid Flake8 F821 warnings about them they are imported explicitly. +# To avoid PyRight `reportUndefinedVariable` errors about them they are imported explicitly. # When using other Scons functions please add them to the line below. -from SCons.Script import BoolVariable, Builder, Copy, Environment, Variables - -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 +from SCons.Script import EnsurePythonVersion, Variables, BoolVariable, Environment, Copy +# Imports for type hints +from SCons.Node import FS -def md2html(source, dest): - 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 = _(buildVars.addon_info["addon_summary"]) - except Exception: - summary = buildVars.addon_info["addon_summary"] - title = "{addonSummary} {addonVersion}".format( - addonSummary=summary, addonVersion=buildVars.addon_info["addon_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([ - "", - "" % lang, - "", - "" - "", - "", - "%s" % title, - "\n", - htmlText, - "\n" - ]) - with codecs.open(dest, "w", "utf-8") as f: - f.write(docText) +# 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) +# Bytecode should not be written for build vars module to keep the repository root folder clean. +sys.dont_write_bytecode = True -def mdTool(env): - mdAction = env.Action( - lambda target, source, env: md2html(source[0].path, target[0].path), - lambda target, source, env: 'Generating % s' % target[0], - ) - mdBuilder = env.Builder( - action=mdAction, - suffix='.html', - src_suffix='.md', - ) - env['BUILDERS']['markdown'] = mdBuilder +import buildVars # NOQA: E402 -def validateVersionNumber(key, val, env): +def validateVersionNumber(key: str, val: str, _): # 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, in which case json generator will validate this info. + # 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)") + raise ValueError(f"{key} 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") + raise ValueError(f"{key} (major.minor.patch) must be integers") + + +def expandGlobs(patterns: Iterable[str], rootdir: Path = Path(".")) -> list[FS.Entry]: + return [env.Entry(e) for pattern in patterns for e in rootdir.glob(pattern.lstrip('/'))] + + +addonDir: Final = Path("addon/") +localeDir: Final = addonDir / "locale" +docsDir: Final = addonDir / "doc" vars = Variables() @@ -93,203 +56,49 @@ vars.Add("versionNumber", "Version number of the form major.minor.patch", "0.0.0 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) +env = Environment(variables=vars, ENV=os.environ, tools=["gettexttool", "NVDATool"]) +env.Append( + addon_info=buildVars.addon_info, + brailleTables=buildVars.brailleTables, + symbolDictionaries=buildVars.symbolDictionaries, + speechDictionaries=buildVars.speechDictionaries, +) 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" + from datetime import date + + versionTimestamp = date.today().strftime('%Y%m%d') + version = f"{versionTimestamp}.0.0" + env["addon_info"]["addon_version"] = version + env["versionNumber"] = version env["channel"] = "dev" elif env["version"] is not None: - env["addon_version"] = env["version"] + env["addon_info"]["addon_version"] = env["version"] if "channel" in env and env["channel"] is not None: - env["addon_updateChannel"] = env["channel"] - -buildVars.addon_info["addon_version"] = env["addon_version"] -buildVars.addon_info["addon_updateChannel"] = env["addon_updateChannel"] - -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: "Generating Addon %s" % 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: "Generating manifest %s" % 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: "Generating translated manifest %s" % target[0] - ) - return action - - -env['BUILDERS']['NVDAAddon'] = Builder(generator=addonGenerator) -env['BUILDERS']['NVDAManifest'] = Builder(generator=manifestGenerator) -env['BUILDERS']['NVDATranslatedManifest'] = Builder(generator=translatedManifestGenerator) - - -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) + env["addon_info"]["addon_updateChannel"] = env["channel"] +# This is necessary for further use in formatting file names. +env.Append(**env["addon_info"]) -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) - # Add-on store does not require submitting json files. - # createAddonStoreJson(dest) - return dest +addonFile = env.File("${addon_name}-${addon_version}.nvda-addon") +addon = env.NVDAAddon(addonFile, env.Dir(addonDir), excludePatterns=buildVars.excludedFiles) -def createAddonStoreJson(bundle): - """Creates add-on store JSON file from an add-on package and manifest data.""" - import json - import hashlib - # Set different json file names and version number properties based on version number parsing results. - if env["versionNumber"] == "0.0.0": - env["versionNumber"] = buildVars.addon_info["addon_version"] - versionNumberParsed = env["versionNumber"].split(".") - if all([part.isnumeric() for part in versionNumberParsed]): - if len(versionNumberParsed) == 1: - versionNumberParsed += ["0", "0"] - elif len(versionNumberParsed) == 2: - versionNumberParsed.append("0") - else: - versionNumberParsed = [] - if len(versionNumberParsed): - major, minor, patch = [int(part) for part in versionNumberParsed] - jsonFilename = f'{major}.{minor}.{patch}.json' - else: - jsonFilename = f'{buildVars.addon_info["addon_version"]}.json' - major, minor, patch = 0, 0, 0 - print('Generating % s' % jsonFilename) - sha256 = hashlib.sha256() - with open(bundle, "rb") as f: - for byte_block in iter(lambda: f.read(65536), b""): - sha256.update(byte_block) - hashValue = sha256.hexdigest() - try: - minimumNVDAVersion = buildVars.addon_info["addon_minimumNVDAVersion"].split(".") - except AttributeError: - minimumNVDAVersion = [0, 0, 0] - minMajor, minMinor = minimumNVDAVersion[:2] - minPatch = minimumNVDAVersion[-1] if len(minimumNVDAVersion) == 3 else "0" - try: - lastTestedNVDAVersion = buildVars.addon_info["addon_lastTestedNVDAVersion"].split(".") - except AttributeError: - lastTestedNVDAVersion = [0, 0, 0] - lastTestedMajor, lastTestedMinor = lastTestedNVDAVersion[:2] - lastTestedPatch = lastTestedNVDAVersion[-1] if len(lastTestedNVDAVersion) == 3 else "0" - channel = buildVars.addon_info["addon_updateChannel"] - if channel is None: - channel = "stable" - addonStoreEntry = { - "addonId": buildVars.addon_info["addon_name"], - "displayName": buildVars.addon_info["addon_summary"], - "URL": "", - "description": buildVars.addon_info["addon_description"], - "sha256": hashValue, - "homepage": buildVars.addon_info["addon_url"], - "addonVersionName": buildVars.addon_info["addon_version"], - "addonVersionNumber": { - "major": major, - "minor": minor, - "patch": patch - }, - "minNVDAVersion": { - "major": int(minMajor), - "minor": int(minMinor), - "patch": int(minPatch) - }, - "lastTestedVersion": { - "major": int(lastTestedMajor), - "minor": int(lastTestedMinor), - "patch": int(lastTestedPatch) - }, - "channel": channel, - "publisher": "", - "sourceURL": buildVars.addon_info["addon_sourceURL"], - "license": buildVars.addon_info["addon_license"], - "licenseURL": buildVars.addon_info["addon_licenseURL"], - } - with open(jsonFilename, "w") as addonStoreJson: - json.dump(addonStoreEntry, addonStoreJson, indent="\t") - - -def generateManifest(source, dest): - addon_info = buildVars.addon_info - with codecs.open(source, "r", "utf-8") as f: - manifest_template = f.read() - manifest = manifest_template.format(**addon_info) - 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) - with codecs.open(out, "w", "utf-8") as f: - f.write(result) - - -def expandGlobs(files): - return [f for pattern in files for f in env.Glob(pattern)] - - -addon = env.NVDAAddon(addonFile, env.Dir('addon')) - -langDirs = [f for f in env.Glob(os.path.join("addon", "locale", "*"))] +langDirs: list[FS.Dir] = [env.Dir(d) for d in env.Glob(localeDir/"*/") if d.isdir()] # Allow all NVDA's gettext po files to be compiled in source/locale, and manifest files to be generated +moByLang: dict[str, FS.File] = {} for dir in langDirs: poFile = dir.File(os.path.join("LC_MESSAGES", "nvda.po")) - moFile = env.gettextMoFile(poFile) - env.Depends(moFile, poFile) + moTarget = env.gettextMoFile(poFile) + moFile = env.File(moTarget[0]) + moByLang[dir.name] = moFile + env.Depends(moTarget, poFile) translatedManifest = env.NVDATranslatedManifest( - dir.File("manifest.ini"), - [moFile, os.path.join("manifest-translated.ini.tpl")] + dir.File("manifest.ini"), [moFile, "manifest-translated.ini.tpl"] ) env.Depends(translatedManifest, ["buildVars.py"]) - env.Depends(addon, [translatedManifest, moFile]) + env.Depends(addon, [translatedManifest, moTarget]) pythonFiles = expandGlobs(buildVars.pythonSources) for file in pythonFiles: @@ -297,37 +106,47 @@ for file in pythonFiles: # 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')): - htmlFile = env.markdown(mdFile) - try: # It is possible that no moFile was set, because an add-on has no translations. - moFile - except NameError: # Runs if there is no moFile - env.Depends(htmlFile, mdFile) - else: # Runs if there is a moFile - env.Depends(htmlFile, [mdFile, moFile]) +if (cssFile := Path("style.css")).is_file(): + cssPath = docsDir / cssFile + cssTarget = env.Command(str(cssPath), str(cssFile), Copy("$TARGET", "$SOURCE")) + env.Depends(addon, cssTarget) + +if (readmeFile := Path("readme.md")).is_file(): + readmePath = docsDir / buildVars.baseLanguage / readmeFile + readmeTarget = env.Command(str(readmePath), str(readmeFile), Copy("$TARGET", "$SOURCE")) + env.Depends(addon, readmeTarget) + +for mdFile in env.Glob(docsDir/"*/*.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 = mdFile.dir.name + moFile = moByLang.get(lang) + htmlFile = env.md2html(mdFile, moFile=moFile, mdExtensions=buildVars.markdownExtensions) + 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': buildVars.addon_info['addon_version'] +gettextvars: dict[str, str] = { + "gettext_package_bugs_address": "nvda-translations@groups.io", + "gettext_package_name": buildVars.addon_info["addon_name"], + "gettext_package_version": buildVars.addon_info["addon_version"], } pot = env.gettextPotFile("${addon_name}.pot", i18nFiles, **gettextvars) -env.Alias('pot', pot) +env.Alias("pot", pot) env.Depends(pot, i18nFiles) mergePot = env.gettextMergePotFile("${addon_name}-merge.pot", i18nFiles, **gettextvars) -env.Alias('mergePot', mergePot) +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")) +manifest = env.NVDAManifest(env.File(addonDir/"manifest.ini"), "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 + '/']) +env.Clean(addon, [".sconsign.dblite", "addon/doc/" + buildVars.baseLanguage + "/"]) diff --git a/site_scons/site_tools/NVDATool/__init__.py b/site_scons/site_tools/NVDATool/__init__.py new file mode 100644 index 0000000..a71857d --- /dev/null +++ b/site_scons/site_tools/NVDATool/__init__.py @@ -0,0 +1,114 @@ +""" +This tool generates NVDA extensions. + +Builders: + +- NVDAAddon: Creates a .nvda-addon zip file. Requires the `excludePatterns` environment variable. +- NVDAManifest: Creates the manifest.ini file. +- NVDATranslatedManifest: Creates the manifest.ini file with only translated information. +- md2html: Build HTML from Markdown + +The following environment variables are required to create the manifest: + +- addon_info: .typing.AddonInfo +- brailleTables: .typings.BrailleTables +- symbolDictionaries: .typings.SymbolDictionaries +- speechDictionaries: .typings.SpeechDictionaries + +The following environment variables are required to build the HTML: + +- moFile: str | pathlib.Path | None +- mdExtensions: list[str] +- addon_info: .typings.AddonInfo + +""" + +from SCons.Script import Environment, Builder + +from .addon import createAddonBundleFromPath +from .manifests import generateManifest, generateTranslatedManifest +from .docs import md2html + + +def generate(env: Environment): + env.SetDefault(excludePatterns=tuple()) + + addonAction = env.Action( + lambda target, source, env: createAddonBundleFromPath( + source[0].abspath, + target[0].abspath, + env["excludePatterns"], + ) + and None, + lambda target, source, env: f"Generating Addon {target[0]}", + ) + env["BUILDERS"]["NVDAAddon"] = Builder( + action=addonAction, + suffix=".nvda-addon", + src_suffix="/", + ) + + env.SetDefault(brailleTables={}) + env.SetDefault(symbolDictionaries={}) + env.SetDefault(speechDictionaries={}) + + manifestAction = env.Action( + lambda target, source, env: generateManifest( + source[0].abspath, + target[0].abspath, + addon_info=env["addon_info"], + brailleTables=env["brailleTables"], + symbolDictionaries=env["symbolDictionaries"], + speechDictionaries=env["speechDictionaries"], + ) + and None, + lambda target, source, env: f"Generating manifest {target[0]}", + ) + env["BUILDERS"]["NVDAManifest"] = Builder( + action=manifestAction, + suffix=".ini", + src_siffix=".ini.tpl", + ) + + translatedManifestAction = env.Action( + lambda target, source, env: generateTranslatedManifest( + source[1].abspath, + target[0].abspath, + mo=source[0].abspath, + addon_info=env["addon_info"], + brailleTables=env["brailleTables"], + symbolDictionaries=env["symbolDictionaries"], + speechDictionaries=env["speechDictionaries"], + ) + and None, + lambda target, source, env: f"Generating translated manifest {target[0]}", + ) + + env["BUILDERS"]["NVDATranslatedManifest"] = Builder( + action=translatedManifestAction, + suffix=".ini", + src_siffix=".ini.tpl", + ) + + env.SetDefault(mdExtensions={}) + + mdAction = env.Action( + lambda target, source, env: md2html( + source[0].path, + target[0].path, + moFile=env["moFile"].path if env["moFile"] else None, + mdExtensions=env["mdExtensions"], + addon_info=env["addon_info"], + ) + and None, + lambda target, source, env: f"Generating {target[0]}", + ) + env["BUILDERS"]["md2html"] = env.Builder( + action=mdAction, + suffix=".html", + src_suffix=".md", + ) + + +def exists(): + return True diff --git a/site_scons/site_tools/NVDATool/addon.py b/site_scons/site_tools/NVDATool/addon.py new file mode 100644 index 0000000..7d67516 --- /dev/null +++ b/site_scons/site_tools/NVDATool/addon.py @@ -0,0 +1,23 @@ +import zipfile +from collections.abc import Iterable +from pathlib import Path + + +def matchesNoPatterns(path: Path, patterns: Iterable[str]) -> bool: + """Checks if the path, the first argument, does not match any of the patterns passed as the second argument.""" + return not any((path.match(pattern) for pattern in patterns)) + + +def createAddonBundleFromPath(path: str | Path, dest: str, excludePatterns: Iterable[str]): + """Creates a bundle from a directory that contains an addon manifest file.""" + if isinstance(path, str): + path = Path(path) + basedir = path.absolute() + with zipfile.ZipFile(dest, "w", zipfile.ZIP_DEFLATED) as z: + for p in basedir.rglob("*"): + if p.is_dir(): + continue + pathInBundle = p.relative_to(basedir) + if matchesNoPatterns(pathInBundle, excludePatterns): + z.write(p, pathInBundle) + return dest diff --git a/site_scons/site_tools/NVDATool/docs.py b/site_scons/site_tools/NVDATool/docs.py new file mode 100644 index 0000000..e1f80ad --- /dev/null +++ b/site_scons/site_tools/NVDATool/docs.py @@ -0,0 +1,59 @@ +import gettext +from pathlib import Path + +import markdown + +from .typings import AddonInfo + + +def md2html( + source: str | Path, + dest: str | Path, + *, + moFile: str | Path | None, + mdExtensions: list[str], + addon_info: AddonInfo, +): + if isinstance(source, str): + source = Path(source) + if isinstance(dest, str): + dest = Path(dest) + if isinstance(moFile, str): + moFile = Path(moFile) + + try: + with moFile.open("rb") as f: + _ = gettext.GNUTranslations(f).gettext + except Exception: + summary = addon_info["addon_summary"] + else: + summary = _(addon_info["addon_summary"]) + version = addon_info["addon_version"] + title = f"{summary} {version}" + lang = source.parent.name.replace("_", "-") + headerDic = { + '[[!meta title="': "# ", + '"]]': " #", + } + with source.open("r", encoding="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 dest.open("w", encoding="utf-8") as f: + f.write(docText) # type: ignore diff --git a/site_scons/site_tools/NVDATool/manifests.py b/site_scons/site_tools/NVDATool/manifests.py new file mode 100644 index 0000000..7723b0b --- /dev/null +++ b/site_scons/site_tools/NVDATool/manifests.py @@ -0,0 +1,77 @@ +import codecs +import gettext +from functools import partial + +from .typings import AddonInfo, BrailleTables, SymbolDictionaries, SpeechDictionaries +from .utils import format_nested_section + + +def generateManifest( + source: str, + dest: str, + addon_info: AddonInfo, + brailleTables: BrailleTables, + symbolDictionaries: SymbolDictionaries, + speechDictionaries: SpeechDictionaries, +): + # Prepare the root manifest section + 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 brailleTables: + manifest += format_nested_section("brailleTables", brailleTables) + + # Custom speech symbol dictionaries + if symbolDictionaries: + manifest += format_nested_section("symbolDictionaries", symbolDictionaries) + + # Custom speech pronunciation dictionaries + if speechDictionaries: + manifest += format_nested_section("speechDictionaries", speechDictionaries) + + with codecs.open(dest, "w", "utf-8") as f: + f.write(manifest) + + +def generateTranslatedManifest( + source: str, + dest: str, + *, + mo: str, + addon_info: AddonInfo, + brailleTables: BrailleTables, + symbolDictionaries: SymbolDictionaries, + speechDictionaries: SpeechDictionaries, +): + with open(mo, "rb") as f: + _ = gettext.GNUTranslations(f).gettext + vars: dict[str, str] = {} + for var in ("addon_summary", "addon_description", "addon_changelog"): + vars[var] = _(addon_info[var]) + with codecs.open(source, "r", "utf-8") as f: + manifest_template = f.read() + manifest = manifest_template.format(**vars) + + _format_section_only_with_displayName = partial( + format_nested_section, + include_only_keys=("displayName",), + _=_, + ) + + # Add additional manifest sections such as custom braile tables + # Custom braille translation tables + if brailleTables: + manifest += _format_section_only_with_displayName("brailleTables", brailleTables) + + # Custom speech symbol dictionaries + if symbolDictionaries: + manifest += _format_section_only_with_displayName("symbolDictionaries", symbolDictionaries) + + # Custom speech pronunciation dictionaries + if speechDictionaries: + manifest += _format_section_only_with_displayName("speechDictionaries", speechDictionaries) + + with codecs.open(dest, "w", "utf-8") as f: + f.write(manifest) diff --git a/site_scons/site_tools/NVDATool/typings.py b/site_scons/site_tools/NVDATool/typings.py new file mode 100644 index 0000000..0375538 --- /dev/null +++ b/site_scons/site_tools/NVDATool/typings.py @@ -0,0 +1,44 @@ +from typing import TypedDict, Protocol + + +class AddonInfo(TypedDict): + addon_name: str + addon_summary: str + addon_description: str + addon_version: str + addon_changelog: str + addon_author: str + addon_url: str | None + addon_sourceURL: str | None + addon_docFileName: str + addon_minimumNVDAVersion: str | None + addon_lastTestedNVDAVersion: str | None + addon_updateChannel: str | None + addon_license: str | None + addon_licenseURL: str | None + + +class BrailleTableAttributes(TypedDict): + displayName: str + contracted: bool + output: bool + input: bool + + +class SymbolDictionaryAttributes(TypedDict): + displayName: str + mandatory: bool + + +class SpeechDictionaryAttributes(TypedDict): + displayName: str + mandatory: bool + + +BrailleTables = dict[str, BrailleTableAttributes] +SymbolDictionaries = dict[str, SymbolDictionaryAttributes] +SpeechDictionaries = dict[str, SpeechDictionaryAttributes] + + +class Strable(Protocol): + def __str__(self) -> str: ... diff --git a/site_scons/site_tools/NVDATool/utils.py b/site_scons/site_tools/NVDATool/utils.py new file mode 100644 index 0000000..c900841 --- /dev/null +++ b/site_scons/site_tools/NVDATool/utils.py @@ -0,0 +1,27 @@ +from collections.abc import Callable, Container, Mapping + +from .typings import Strable + + +def _(arg: str) -> str: + """ + A function that passes the string to it without doing anything to it. + Needed for recognizing strings for translation by Gettext. + """ + return arg + + +def format_nested_section( + section_name: str, + data: Mapping[str, Mapping[str, Strable]], + include_only_keys: Container[str] | None = None, + _: Callable[[str], str] = _, +) -> str: + lines = [f"\n[{section_name}]"] + for item_name, inner_dict in data.items(): + lines.append(f"[[{item_name}]]") + for key, val in inner_dict.items(): + if include_only_keys and key not in include_only_keys: + continue + lines.append(f"{key} = {_(str(val))}") + return "\n".join(lines) + "\n" diff --git a/site_scons/site_tools/gettexttool/__init__.py b/site_scons/site_tools/gettexttool/__init__.py index fa3a937..ff4697e 100644 --- a/site_scons/site_tools/gettexttool/__init__.py +++ b/site_scons/site_tools/gettexttool/__init__.py @@ -1,4 +1,4 @@ -""" This tool allows generation of gettext .mo compiled files, pot files from source code files +"""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: @@ -15,35 +15,43 @@ """ + 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( + env["BUILDERS"]["gettextMoFile"] = env.Builder( action=Action("msgfmt -o $TARGET $SOURCE", "Compiling translation $SOURCE"), suffix=".mo", - src_suffix=".po" + src_suffix=".po", ) - env['BUILDERS']['gettextPotFile']=env.Builder( + 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") + 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/updateVersion.py b/updateVersion.py index c53e16f..437b3dd 100644 --- a/updateVersion.py +++ b/updateVersion.py @@ -7,7 +7,7 @@ print(f"the version recognized is: {version}") with open("buildVars.py", 'r+', encoding='utf-8') as f: text = f.read() - text = re.sub('"addon_version" *:.*,', f'"addon_version" : "{version}",', text) + text = re.sub('version *=.*', f'version = {version}', text, count=1) f.seek(0) f.write(text) f.truncate()