diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml new file mode 100644 index 0000000..108bd1c --- /dev/null +++ b/.github/workflows/pr-checks.yml @@ -0,0 +1,49 @@ +name: PR Checks + +on: + pull_request: + branches: [main, master] + workflow_dispatch: + +permissions: + contents: read + pull-requests: write + +jobs: + validate: + uses: ./.github/workflows/validate.yml + with: + upload_artifacts: true + + comment_packaged_artifact: + runs-on: ubuntu-latest + needs: [validate] + if: github.event_name == 'pull_request' + steps: + - name: Comment packaged artifact link on PR + uses: actions/github-script@v7 + env: + ARTIFACT_URL: ${{ needs.validate.outputs.artifact_url }} + with: + script: | + const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; + const commitSha = context.payload.pull_request?.head?.sha || context.sha; + const shortSha = commitSha.slice(0, 7); + const artifactLine = process.env.ARTIFACT_URL + ? `- Artifact: ${process.env.ARTIFACT_URL}` + : '- Artifact: not available (check the run page below)'; + + const body = [ + '### Packaged add-on artifact', + `- Commit: ${shortSha} (${commitSha})`, + artifactLine, + `- Run: ${runUrl}`, + ].join('\n'); + + const issue_number = context.payload.pull_request.number; + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number, + body, + }); diff --git a/.github/workflows/pr-validation.yml b/.github/workflows/pr-validation.yml deleted file mode 100644 index df49e73..0000000 --- a/.github/workflows/pr-validation.yml +++ /dev/null @@ -1,29 +0,0 @@ -name: CustomAppModuleMapperPrValidation -on: - pull_request: - types: [opened, synchronize, reopened] -jobs: - Validate_PR: - runs-on: "ubuntu-latest" - steps: - - name: Checkout code - uses: actions/checkout@v3 - - name: install system dependencies - run: | - sudo apt update - sudo apt install -y gettext - - name: Install python - uses: actions/setup-python@v4 - with: - # it seems that x86 versions of python 3 are not available for linux install. - # In this addon context it is really not important, as packaging should not deppend on architecture versions - # However, for future NVDA related actions we might have to switch to windows runners - python-version: "3.11" - - name: install python dependencies - run: | - pip install scons - pip install markdown - pip install pre-commit - - name: validate pr - run: | - pre-commit run --all-files diff --git a/.github/workflows/release-addon.yml b/.github/workflows/release-addon.yml deleted file mode 100644 index 5af3b63..0000000 --- a/.github/workflows/release-addon.yml +++ /dev/null @@ -1,62 +0,0 @@ -name: CustomAppModuleMapperCI -on: - push: - tags: - - "**" -jobs: - Release_addon: - runs-on: "ubuntu-latest" - steps: - - name: Checkout code - uses: actions/checkout@v3 - # The next step is used to fix a small issue here to obtain the current tag message, which will be used as the release body. For more details - # see https://github.com/actions/checkout/issues/290 - - name: make sure we have the correct tag information - run: git fetch --tags --force - - name: Obtain tag message - uses: ericcornelissen/git-tag-annotation-action@v2 - id: tag-data - - name: Check differences to main branch - id: differences-to-main - run: | - if git diff origin/main --exit-code; then - echo "::set-output name=changes_exist::false" - else - echo "::set-output name=changes_exist::true" - fi - - name: abort if tag is not applied on top of main - if: ${{ steps.differences-to-main.outputs.changes_exist == 'true' }} - uses: actions/github-script@v3 - with: - script: | - core.setFailed('Releases can be generated only from commit on head of main branch') - - name: install system dependencies - run: | - sudo apt update - sudo apt install -y gettext - - name: Install python - uses: actions/setup-python@v4 - with: - # it seems that x86 versions of python 3 are not available for linux install. - # In this addon context it is really not important, as packaging should not deppend on architecture versions - # However, for future NVDA related actions we might have to switch to windows runners - python-version: "3.11" - - name: install python dependencies - run: | - pip install scons - pip install markdown - pip install pre-commit - - name: validate addon - run: | - pre-commit run --all-files - - name: build addon - run: | - rm -f *.nvda-addon || true - scons - - name: Release addon - uses: softprops/action-gh-release@v2 - with: - files: "*.nvda-addon" - body: "${{ steps.tag-data.outputs.git-tag-annotation }}" - fail_on_unmatched_files: true - generate_release_notes: false diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..38ace77 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,75 @@ +name: Release Add-on + +on: + push: + tags: + - "**" + workflow_dispatch: + +jobs: + validate: + uses: ./.github/workflows/validate.yml + with: + upload_artifacts: false + + release_addon: + if: startsWith(github.ref, 'refs/tags/') + needs: [validate] + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: + actions/checkout@v4 + + # The next step is used to fix a small issue here to obtain the current tag message, + # which will be used as the release body. For more details: + # https://github.com/actions/checkout/issues/290 + - name: Make sure we have the correct tag information + run: git fetch --tags --force + + - name: Obtain tag message + uses: ericcornelissen/git-tag-annotation-action@v2 + id: tag-data + + - name: Check differences to main branch + id: differences-to-main + run: | + git fetch origin main --depth=1 + if git diff origin/main --exit-code; then + echo "changes_exist=false" >> "$GITHUB_OUTPUT" + else + echo "changes_exist=true" >> "$GITHUB_OUTPUT" + fi + + - name: Abort if tag is not applied on top of main + if: steps.differences-to-main.outputs.changes_exist == 'true' + uses: actions/github-script@v7 + with: + script: | + core.setFailed('Releases can be generated only from commit on head of main branch') + + - name: Install system dependencies + run: sudo apt install gettext + + - name: Install Python + uses: actions/setup-python@v5 + with: + python-version: "3.13" + + - name: Install Python dependencies + run: | + pip install scons + pip install markdown + + - name: Generate addon + run: | + rm -f *.nvda-addon || true + scons + + - name: Release + uses: softprops/action-gh-release@v1 + with: + files: "*.nvda-addon" + body: "${{ steps.tag-data.outputs.git-tag-annotation }}" + fail_on_unmatched_files: true + generate_release_notes: false diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml new file mode 100644 index 0000000..58d9374 --- /dev/null +++ b/.github/workflows/validate.yml @@ -0,0 +1,60 @@ +name: Validate Add-on + +on: + workflow_call: + inputs: + upload_artifacts: + description: Upload build artifacts for this validation run + required: false + type: boolean + default: false + outputs: + artifact_url: + description: URL for the uploaded artifact (when upload_artifacts is true) + value: ${{ jobs.build_and_check.outputs.artifact_url }} + workflow_dispatch: + +jobs: + build_and_check: + runs-on: ubuntu-latest + env: + ARTIFACT_RETENTION_DAYS: 7 + outputs: + artifact_url: ${{ steps.upload_build_artifacts.outputs.artifact-url }} + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install system dependencies + run: sudo apt install gettext + + - name: Install Python + uses: actions/setup-python@v5 + with: + python-version: "3.13" + + - name: Install Python dependencies + run: | + pip install pre-commit + pip install scons + pip install markdown + + - name: Code checks + run: pre-commit run --all-files + + - name: Build addon and pot + run: | + rm -f *.nvda-addon *.pot || true + scons + scons pot + + - name: Upload build artifacts + id: upload_build_artifacts + if: ${{ inputs.upload_artifacts }} + uses: actions/upload-artifact@v4 + with: + name: packaged_addon + retention-days: ${{ env.ARTIFACT_RETENTION_DAYS }} + path: | + ./*.nvda-addon + ./*.pot diff --git a/.gitignore b/.gitignore index ef86b86..18607b7 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,5 @@ addon/doc/**/*.md *.py[co] *.nvda-addon .sconsign.dblite +.playwright-mcp/ +test-results/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index f3eefb0..24dcf22 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -6,7 +6,7 @@ repos: - id: check-case-conflict - id: check-yaml - repo: https://github.com/PyCQA/flake8 - rev: 4.0.1 + rev: 7.1.1 hooks: - id: flake8 args: [--config=flake8.ini] diff --git a/README.md b/README.md index 34323a9..ebe8916 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,9 @@ -# CustomAppModulesMapper 0.2.0 +# CustomAppModulesMapper 0.3.0 Nvda ADDON for allowing dynamic mapping of existing app modules for other applications. ## download -Download the [CustomAppModulesMapper 0.2.0 addon](https://github.com/marlon-sousa/CustomAppModulesMapper/releases/download/0.2.0/CustomAppModulesMapper-0.2.0.nvda-addon) +Download the [CustomAppModulesMapper 0.3.0 addon](https://github.com/marlon-sousa/CustomAppModulesMapper/releases/download/0.3.0/CustomAppModulesMapper-0.3.0.nvda-addon) ## How it works diff --git a/addon/appModules/notAssociated.py b/addon/appModules/notAssociated.py new file mode 100644 index 0000000..244f62c --- /dev/null +++ b/addon/appModules/notAssociated.py @@ -0,0 +1,14 @@ +# -*- coding: UTF-8 -*- +# A part of the CustomAppModulesMapper addon for NVDA +# Copyright (C) 2024 Marlon Sousa +# This file is covered by the MIT License. +# See the file COPYING.txt for more details. + +import appModuleHandler + + +# A deliberately empty app module. Mapping an application to "notAssociated" gives it a neutral +# app module that adds no app specific behavior, which effectively detaches the application from +# whatever module NVDA would otherwise load for it. +class AppModule(appModuleHandler.AppModule): + pass diff --git a/addon/doc/en/contributing.md b/addon/doc/en/contributing.md new file mode 100644 index 0000000..980e029 --- /dev/null +++ b/addon/doc/en/contributing.md @@ -0,0 +1,77 @@ +# Contributing + + +## building the addon + + +### Local environment + +Although not mandatory, we suggest you perform the following: + +1. Clone NVDA in a folder at the same level as this project. +For example, if this project is cloned at c:\projects\CustomAppModulesMapper, NVDA should be cloned at c:\projects\nvda. +Perform a clone with the --recursive flag. If NVDA is already cloned, make sure it is up to date, fetching and then synchronizing the master branch with the NVDA upstream master branch. +Perform a git submodule update --init command to make sure git submodules are correctly synchronized. +2. Perform a git checkout in the release-2026.1 tag in the NVDA project. You don't need to build NVDA, just having the source code at the release branch is enough. +3. Install visual studio code, if it is not installed. Although you can use other environments, the optimal setup requires visual studio code. +4. Open the CustomAppModulesMapper folder in VS Code. From command line, perform the "code ." command, or use the file / open folder menus on visual studio code itself and select the folder where this project is cloned. +5. Use ctrl + shift + x to access the extensions widget in Visual studio code. Tab umtil the recomended section and install the recomended extensions. +6. Restart visual studio code. +7. Now, whenever you are navigating through code, pressing f12 in a NVDA object should take you to its source in NVDA project. + +### Dependencies + +You will need: + +* python 3.13. +* pip must be configured +* scons (pip install scons) +* markdown (pip install markdown) +* gettext, which provides the `msgfmt` and `xgettext` utilities. `msgfmt` compiles the translation files on every build, and `xgettext` is used by `scons pot` to generate the translation template. On Windows, install a modern build from [gettext-iconv-windows](https://github.com/mlocati/gettext-iconv-windows/releases) (or use `scoop install gettext` / `choco install gettext`), and make sure its `bin` directory comes before any other gettext on your PATH. Do not use the GnuWin32 gettext package: it is frozen at version 0.14.4 (2005) and is too old for this build (`scons pot` fails on the unsupported `--package-name` option). + + +#### Pre-commit + +It is strongly recomended that you install pre-commit. + +* pip imstall pre-commit +* pre-commit install + +This will imstall pre-commit and configure its hooks, so that whenever you perform a commit several checks will apply. +Should any of them fail, the commit will not be allowed. +This helps to ensure your commits have quality. You can bypass the check, however be aware that a pull request check will also apply these same checks and merge will be disabled should any of them fail, even if someone approves the pull request. + +You can trigger the pre-commit checks at any time without performing a commit by issuing "pre-commit run --all-files". + +#### Flake8 + +One of the pre-commit hooks is flake8, a python linter which, ammong other things, help to make sure the project has a consistent formating and that good practices are in place. + +Visual Studio code recomended extensions include flake8, so that you can be warned while editing code when something needs to be fixed. + +The visual studio code extension and the pre-commit flake8 hooks use the same configuration. + +### building + +Once you have everything installed, issuing scons at the root of the project should build the addon and generate docs. + +## translations + +### translating the addon + +Assuming you have everything set up to build the addom (see previous topic) issuing scons pot should generate a pot file at the root project directory. It is them possible to generate and contribute the .po files for your language. +Current languages can be found at /addon/locale directory + +### translating documentation + +Documentation translations are generated from .tpl.md (not from .md) files. This is why, except from this file (read.md) at the root of the project, you won't find other .md files. + +The .tpl.md files are normal markdown files with one addition: if you use ${[var]} within its text, [var] will be replaced by a var with the same name defined in buildvars.py when the corresponding md and.html files are generated. + +If no variable with that name exists, the substitution doesn't take place. + +This is useful for example to generate links and titles with the addon version included without having to rewrite documentation. + +To translate documentation, grab the readme.tpl.md file at the root of the project and translate it. The translated file must be named readme.tpl.md and must be placed inside the addon/doc/[lang] directory. + +The ${[xxx]} vars need to stay untouched. To generate the docs, issue scons and the markdown and HTML will be generated. diff --git a/addon/globalPlugins/CustomAppModulesMapper/guiHelper.py b/addon/globalPlugins/CustomAppModulesMapper/guiHelper.py index 3c8a772..945ae39 100644 --- a/addon/globalPlugins/CustomAppModulesMapper/guiHelper.py +++ b/addon/globalPlugins/CustomAppModulesMapper/guiHelper.py @@ -96,8 +96,9 @@ def onAddDialogResumed(self, item: CustomMappingItem): def onAdd(self, evt): title = _("add mapping") gui.mainFrame.prePopup() - availableModules = mapperHandler.getAllAvailableAppModules() - dialog = ModuleMappingDialog(self, title, availableModules) + # Pass the mappings currently staged in this panel so the dialog can tell whether the app + # the user types is already mapped (MODIFY) or new (ADD), and preserve its original module. + dialog = ModuleMappingDialog(self, title, self.mappings) if dialog.ShowModal() == wx.ID_OK: self.onAddDialogResumed(dialog.result) dialog.Destroy() @@ -145,10 +146,11 @@ class ModuleMappingDialog( wx.Dialog, # wxPython does not seem to call base class initializer, put last in MRO ): - def __init__(self, parent, title, customModulesMapping): + def __init__(self, parent, title, currentMappings): super(ModuleMappingDialog, self).__init__(parent, title=title) self.result = None - self.customModulesMapping = customModulesMapping + # currentMappings maps an app name to the CustomMappingItem currently staged for it. + self.currentMappings = currentMappings self.availableModules = mapperHandler.getAllAvailableAppModules() mainSizer = wx.BoxSizer(wx.VERTICAL) sHelper = guiHelper.BoxSizerHelper(self, orientation=wx.VERTICAL) @@ -179,10 +181,13 @@ def onOk(self, evt): wx.MessageBox(_("Please fill all fields"), _("Error"), wx.OK | wx.ICON_ERROR) return originalMapping = mapperHandler.getAllConfiguredMappings() - action = CustomMappingAction.ADD if app not in self.customModulesMapping else CustomMappingAction.MODIFY # noqa E501 - if app in self.customModulesMapping: - originalModule = self.customModulesMapping[app].appModule + if app in self.currentMappings: + # The app is already mapped: keep the module NVDA had before any custom mapping so that + # removing the mapping later restores the correct original. + action = CustomMappingAction.MODIFY + originalModule = self.currentMappings[app].appOriginalModule else: + action = CustomMappingAction.ADD originalModule = originalMapping.get(app, None) self.result = CustomMappingItem(app, appModule, originalModule, action) evt.Skip() diff --git a/addon/globalPlugins/CustomAppModulesMapper/mapperHandler.py b/addon/globalPlugins/CustomAppModulesMapper/mapperHandler.py index 57c947f..ac5ef9f 100644 --- a/addon/globalPlugins/CustomAppModulesMapper/mapperHandler.py +++ b/addon/globalPlugins/CustomAppModulesMapper/mapperHandler.py @@ -6,9 +6,12 @@ import os import pickle +import shutil import addonHandler import appModules import appModuleHandler +import globalVars +import pkgutil from logHandler import log from dataclasses import dataclass from typing import List @@ -33,9 +36,46 @@ def getAllConfiguredMappings() -> dict[str, str]: return appModules.EXECUTABLE_NAMES_TO_APP_MODS | appModuleHandler._executableNamesToAppModsAddons -def getAllAvailableAppModules() -> set[str]: +def filterUnmappedModules(modName): + # These are generic host/internal modules that are not meaningful mapping targets: + # nvda is NVDA itself, and the others are shared hosts (Java, the Edge WebView, the modern + # touch keyboard, etc.) that back many unrelated apps, so offering them as targets would be + # misleading. They are hidden from the list of available modules. + return modName not in ( + 'javaw', + 'messengerWindow', + 'msgComposeWindow', + 'msedgewebview2', + 'nvda', + 'windowsinternal_composableshell_experiences_textinput_inputapp', + ) + + +def getUnmappedModules() -> List[str]: + # appModules.EXECUTABLE_NAMES_TO_APP_MODS only lists the special executable->module aliases + # (e.g. dbeaver -> eclipse). The vast majority of NVDA's app modules are matched implicitly by + # having the same name as the executable, so they never appear in that mapping. To offer every + # available module as a mapping target we enumerate the appModules package directly. This also + # picks up modules provided by other add-ons, since they are merged into the appModules + # namespace package. + modules = [] + for _importer, modName, _ispkg in pkgutil.iter_modules(appModules.__path__): + modules.append(modName) + return modules + + +def getAllAvailableAppModules() -> List[str]: + # The full list of modules a user can map an app to is the union of every module physically + # present in the appModules package and the targets of the currently configured aliases, + # minus the generic host modules filtered out above. + unmappedModules = getUnmappedModules() mappings = getAllConfiguredMappings() - return sorted(set(mappings.values())) + return sorted( + filter( + filterUnmappedModules, + set(list(mappings.values()) + unmappedModules), + ) + ) def associateAppModule(appName: str, moduleName: str): @@ -53,9 +93,31 @@ def restart(): def getCustomMappingsFilePath(): + # Store the mappings in NVDA's user configuration directory rather than in the add-on folder. + # The add-on folder is wiped whenever the add-on is updated or reinstalled, which would + # silently discard the user's custom mappings. globalVars.appArgs.configPath points at the + # active NVDA configuration directory (installed, portable, or the one passed with -c), so + # files placed there survive add-on reinstalls. + return os.path.join(globalVars.appArgs.configPath, "customModulesMapping.pickle") + + +def getLegacyCustomMappingsFilePath(): + # Location used by version 0.2.0 (inside the add-on folder). Kept only so that existing + # mappings can be migrated to the new, reinstall-safe location. addon = addonHandler.getCodeAddon() - addonMainFolder = addon.path - return os.path.join(addonMainFolder, "customModulesMapping.pickle") + return os.path.join(addon.path, "customModulesMapping.pickle") + + +def migrateLegacyMappingsFile(): + newPath = getCustomMappingsFilePath() + legacyPath = getLegacyCustomMappingsFilePath() + if os.path.isfile(newPath) or not os.path.isfile(legacyPath): + return + try: + shutil.move(legacyPath, newPath) + log.info("Migrated custom mappings to the NVDA configuration directory") + except Exception as e: + log.error(f"Could not migrate custom mappings file: {e}") def persist(): @@ -66,6 +128,7 @@ def persist(): def loadCustomMappings(): global customModulesMapping + migrateLegacyMappingsFile() try: with open(getCustomMappingsFilePath(), "rb") as f: customModulesMapping = pickle.load(f) diff --git a/buildVars.py b/buildVars.py index e44f0e3..5f646b4 100644 --- a/buildVars.py +++ b/buildVars.py @@ -1,88 +1,50 @@ -# -*- 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": "CustomAppModulesMapper", - # 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 found in Add-ons Manager. - "addon_summary": _("CustomAppModulesMapper"), - # Add-on description - # Translators: Long description to be shown for this add-on on add-on information from add-ons manager - "addon_description": _("""Provides custom app modules mapper functionality"""), - # version - "addon_version": "0.2.0", - # Author(s) - "addon_author": "Marlon Brandão de Sousa ", - # URL for the add-on documentation support - "addon_url": None, - # URL for the add-on repository where the source code can be found - "addon_sourceURL": "https://github.com/marlon-sousa/CustomAppModuleMapper", - # Documentation file name - "addon_docFileName": "readme.html", - # Minimum NVDA version supported (e.g. "2018.3.0", minor version is optional) - "addon_minimumNVDAVersion": "2024.1", - # Last NVDA version supported/tested (e.g. "2018.4.0", ideally more recent than minimum version) - "addon_lastTestedNVDAVersion": "2024.3", - # 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": None, - # URL for the license document the ad-on is licensed under - "addon_licenseURL": None, -} - -# 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 = [] - -# 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. -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 = {} +from site_scons.site_tools.NVDATool.typings import AddonInfo, BrailleTables, SymbolDictionaries +from site_scons.site_tools.NVDATool.utils import _ + + +addon_info = AddonInfo( + addon_name="CustomAppModulesMapper", + # Translators: Summary/title for this add-on. + addon_summary=_("Custom App Modules Mapper"), + # Translators: Long description for this add-on in add-on store. + addon_description=_("""Lets you dynamically map any application to any existing NVDA app module, straight from the NVDA settings dialog, without writing an add-on or waiting for a mapping to be merged into NVDA."""), + # Translators: what's new text for this add-on version shown in add-on store. + addon_changelog=_("""Version 0.3.0: +* Compatible with NVDA 2026.1. +* The list of app modules you can map an application to now includes every module bundled with NVDA and every module provided by other add-ons, instead of only the handful that already had an alias. +* Custom mappings are now stored in the NVDA configuration directory, so they survive updating or reinstalling the add-on. Mappings saved by previous versions are migrated automatically. +* Fixed editing an existing mapping so that removing it afterwards restores the correct original module. +* Modernized the add-on build system and project tooling."""), + addon_version="0.3.0", + addon_author="Marlon Brandão de Sousa ", + addon_url="https://github.com/marlon-sousa/CustomAppModulesMapper", + addon_sourceURL="https://github.com/marlon-sousa/CustomAppModulesMapper", + addon_docFileName="readme.html", + addon_minimumNVDAVersion="2024.1", + addon_lastTestedNVDAVersion="2026.1.0", + addon_updateChannel=None, + addon_license="Mit License", + addon_licenseURL="https://github.com/marlon-sousa/CustomAppModulesMapper/blob/main/LICENSE", +) + + +pythonSources: list[str] = [ + "addon/globalPlugins/CustomAppModulesMapper/*.py", + "addon/appModules/*.py", +] +i18nSources: list[str] = pythonSources + ["buildVars.py"] + +# Paths are relative to the addon directory when building the bundle. +excludedFiles: list[str] = [ + "doc/*/contributing*.*", + "doc/*/*.tpl.md", +] + +baseLanguage: str = "en" +markdownExtensions: list[str] = [] + +brailleTables: BrailleTables = {} +symbolDictionaries: SymbolDictionaries = {} diff --git a/contributing.md b/contributing.md new file mode 100644 index 0000000..980e029 --- /dev/null +++ b/contributing.md @@ -0,0 +1,77 @@ +# Contributing + + +## building the addon + + +### Local environment + +Although not mandatory, we suggest you perform the following: + +1. Clone NVDA in a folder at the same level as this project. +For example, if this project is cloned at c:\projects\CustomAppModulesMapper, NVDA should be cloned at c:\projects\nvda. +Perform a clone with the --recursive flag. If NVDA is already cloned, make sure it is up to date, fetching and then synchronizing the master branch with the NVDA upstream master branch. +Perform a git submodule update --init command to make sure git submodules are correctly synchronized. +2. Perform a git checkout in the release-2026.1 tag in the NVDA project. You don't need to build NVDA, just having the source code at the release branch is enough. +3. Install visual studio code, if it is not installed. Although you can use other environments, the optimal setup requires visual studio code. +4. Open the CustomAppModulesMapper folder in VS Code. From command line, perform the "code ." command, or use the file / open folder menus on visual studio code itself and select the folder where this project is cloned. +5. Use ctrl + shift + x to access the extensions widget in Visual studio code. Tab umtil the recomended section and install the recomended extensions. +6. Restart visual studio code. +7. Now, whenever you are navigating through code, pressing f12 in a NVDA object should take you to its source in NVDA project. + +### Dependencies + +You will need: + +* python 3.13. +* pip must be configured +* scons (pip install scons) +* markdown (pip install markdown) +* gettext, which provides the `msgfmt` and `xgettext` utilities. `msgfmt` compiles the translation files on every build, and `xgettext` is used by `scons pot` to generate the translation template. On Windows, install a modern build from [gettext-iconv-windows](https://github.com/mlocati/gettext-iconv-windows/releases) (or use `scoop install gettext` / `choco install gettext`), and make sure its `bin` directory comes before any other gettext on your PATH. Do not use the GnuWin32 gettext package: it is frozen at version 0.14.4 (2005) and is too old for this build (`scons pot` fails on the unsupported `--package-name` option). + + +#### Pre-commit + +It is strongly recomended that you install pre-commit. + +* pip imstall pre-commit +* pre-commit install + +This will imstall pre-commit and configure its hooks, so that whenever you perform a commit several checks will apply. +Should any of them fail, the commit will not be allowed. +This helps to ensure your commits have quality. You can bypass the check, however be aware that a pull request check will also apply these same checks and merge will be disabled should any of them fail, even if someone approves the pull request. + +You can trigger the pre-commit checks at any time without performing a commit by issuing "pre-commit run --all-files". + +#### Flake8 + +One of the pre-commit hooks is flake8, a python linter which, ammong other things, help to make sure the project has a consistent formating and that good practices are in place. + +Visual Studio code recomended extensions include flake8, so that you can be warned while editing code when something needs to be fixed. + +The visual studio code extension and the pre-commit flake8 hooks use the same configuration. + +### building + +Once you have everything installed, issuing scons at the root of the project should build the addon and generate docs. + +## translations + +### translating the addon + +Assuming you have everything set up to build the addom (see previous topic) issuing scons pot should generate a pot file at the root project directory. It is them possible to generate and contribute the .po files for your language. +Current languages can be found at /addon/locale directory + +### translating documentation + +Documentation translations are generated from .tpl.md (not from .md) files. This is why, except from this file (read.md) at the root of the project, you won't find other .md files. + +The .tpl.md files are normal markdown files with one addition: if you use ${[var]} within its text, [var] will be replaced by a var with the same name defined in buildvars.py when the corresponding md and.html files are generated. + +If no variable with that name exists, the substitution doesn't take place. + +This is useful for example to generate links and titles with the addon version included without having to rewrite documentation. + +To translate documentation, grab the readme.tpl.md file at the root of the project and translate it. The translated file must be named readme.tpl.md and must be placed inside the addon/doc/[lang] directory. + +The ${[xxx]} vars need to stay untouched. To generate the docs, issue scons and the markdown and HTML will be generated. diff --git a/flake8.ini b/flake8.ini index 539cf9a..d40e408 100644 --- a/flake8.ini +++ b/flake8.ini @@ -3,21 +3,6 @@ [flake8] -# Plugins -use-flake8-tabs = True -# Not all checks are replaced by flake8-tabs, however, pycodestyle is still not compatible with tabs. -use-pycodestyle-indent = False -continuation-style = hanging -## The following are replaced by flake8-tabs plugin, reported as ET codes rather than E codes. -# E121, E122, E123, E126, E127, E128, -## The following (all disabled) are not replaced by flake8-tabs, -# E124 - Requires mixing spaces and tabs: Closing bracket does not match visual indentation. -# E125 - Does not take tabs into consideration: Continuation line with same indent as next logical line. -# E129 - Requires mixing spaces and tabs: Visually indented line with same indent as next logical line -# E131 - Requires mixing spaces and tabs: Continuation line unaligned for hanging indent -# E133 - Our preference handled by ET126: Closing bracket is missing indentation - - # Reporting statistics = True doctests = True @@ -30,15 +15,14 @@ max-line-length = 110 hang-closing = False ignore = - ET113, # use of alignment as indentation, but option continuation-style=hanging does not permit this - W191, # indentation contains tabs - W503, # line break before binary operator. We want W504(line break after binary operator) + W191, + W503, builtins = # inform flake8 about functions we consider built-in. - _, # translation lookup - ngettext, # translation lookup - pgettext, # translation lookup - npgettext, # translation lookup + _, + ngettext, + pgettext, + npgettext, exclude = # don't bother looking in the following subdirectories / files. .git, diff --git a/manifest-translated.ini.tpl b/manifest-translated.ini.tpl new file mode 100644 index 0000000..6df6d42 --- /dev/null +++ b/manifest-translated.ini.tpl @@ -0,0 +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 eee6e05..8a09d74 100644 --- a/sconstruct +++ b/sconstruct @@ -1,231 +1,177 @@ -# NVDA add-on template SCONSTRUCT file -#Copyright (C) 2012, 2014 Rui Batista -#This file is covered by the GNU General Public License. -#See the file COPYING.txt for more details. +# 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 Any, Final + +from SCons.Script import EnsurePythonVersion, Variables, BoolVariable, Environment, Copy +from SCons.Node import FS + +EnsurePythonVersion(3, 10) + sys.dont_write_bytecode = True -import buildVars - -def md2html(source, dest): - if ".tpl.md" in source: - return - import markdown - lang = os.path.basename(os.path.dirname(source)).replace('_', '-') - title="{addonSummary} {addonVersion}".format(addonSummary=buildVars.addon_info["addon_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) - with codecs.open(dest, "w", "utf-8") as f: - f.write("\n" + - "\n" + - "\n" % (lang, lang) + - "\n" + - "\n" + - "\n" + - "%s\n" % title + - "\n\n" - ) - f.write(htmlText) - f.write("\n\n") - -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: 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. + if val == "0.0.0": + return + versionNumber = val.split(".") + if len(versionNumber) < 3: + raise ValueError(f"{key} must have three parts (major.minor.patch)") + if not all([part.isnumeric() for part in versionNumber]): + 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('/'))] + + +def _expandTemplateMarkdown(source: Path, values: dict[str, Any]) -> Path: + target = Path(str(source).replace(".tpl.md", ".md")) + content = source.read_text(encoding="utf-8") + for k, v in values.items(): + content = content.replace(f"${{{k}}}", "" if v is None else str(v)) + target.write_text(content, encoding="utf-8") + return target + + +addonDir: Final = Path("addon/") +localeDir: Final = addonDir / "locale" +docsDir: Final = addonDir / "doc" + 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) +env = Environment(variables=vars, ENV=os.environ, tools=["gettexttool", "NVDATool"]) +env.Append( + addon_info=buildVars.addon_info, + brailleTables=buildVars.brailleTables, + symbolDictionaries=buildVars.symbolDictionaries, +) if env["dev"]: - import datetime - buildDate = datetime.datetime.now() - year, month, day = str(buildDate.year), str(buildDate.month), str(buildDate.day) - env["addon_version"] = "".join([year, month.zfill(2), day.zfill(2), "-dev"]) - env["channel"] = "dev" + 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"] + env["addon_info"]["addon_updateChannel"] = env["channel"] + +# This is necessary for further use in formatting file names. +env.Append(**env["addon_info"]) + addonFile = env.File("${addon_name}-${addon_version}.nvda-addon") +addon = env.NVDAAddon(addonFile, env.Dir(addonDir), excludePatterns=buildVars.excludedFiles) -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 expandFile(sourceFile): - if not ".tpl" in sourceFile: - return - with codecs.open(sourceFile, "r", "utf-8") as myfile: - content = myfile.read() - for k, v in buildVars.addon_info.items(): - if v: - content = content.replace(f"${{{k}}}", v) - targetFile = sourceFile.replace(".tpl", "") - with codecs.open(targetFile, "w", "utf-8") as myfile: - myfile.write(content) - - - - -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) - # Fix case if README.md is in uppercase, in this case the pipeline failed to build the english documentation because it was running on a Linux system. - for filename in ("readme", "README"): - if os.path.isfile(f"{filename}.tpl.md"): - expandFile(f"{filename}.tpl.md") - readmePath = os.path.join(docsDir, "en", "readme.md") - readmeTarget = env.Command(readmePath, f"{filename}.md", Copy("$TARGET", "$SOURCE")) - env.Depends(addon, readmeTarget) - break - - if os.path.isfile("contributing.md"): - contribPath = os.path.join(docsDir, "en", "contributing.md") - contribTarget = env.Command(contribPath, "contributing.md", Copy("$TARGET", "$SOURCE")) - env.Depends(addon, contribTarget) - - -def createAddonBundleFromPath(path, dest): - """ Creates a bundle from a directory that contains an addon manifest file.""" - excludedFiles = expandGlobs(buildVars.excludedFiles, True) - 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 f"addon\\{pathInBundle}" not in excludedFiles and not ".tpl.md" in pathInBundle: z.write(absPath, pathInBundle) - return dest - -def generateManifest(source, dest): - addon_info = buildVars.addon_info - addon_info["addon_version"] = env["addon_version"] - addon_info["addon_updateChannel"] = env["addon_updateChannel"] - 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): - # No ugettext in Python 3. - if sys.version_info.major == 2: - _ = gettext.translation("nvda", localedir=os.path.join("addon", "locale"), languages=[language]).ugettext - else: - _ = 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, toString = False): - return [f for pattern in files for f in env.Glob(pattern, strings = toString)] - -addon = env.NVDAAddon(addonFile, env.Dir('addon')) - -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 +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) - 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]) + poFile = dir.File(os.path.join("LC_MESSAGES", "nvda.po")) + 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, "manifest-translated.ini.tpl"] + ) + env.Depends(translatedManifest, ["buildVars.py"]) + env.Depends(addon, [translatedManifest, moTarget]) pythonFiles = expandGlobs(buildVars.pythonSources) for file in pythonFiles: - env.Depends(addon, file) - -#Convert markdown files to html -createAddonHelp("addon") # We need at least doc in English and should enable the Help button for the add-on in Add-ons Manager - -# if we have tpl.md files we need to compile them here -for mdFile in env.Glob(os.path.join('addon', 'doc', '*', '*.tpl.md')): - expandFile(mdFile.path) - -for mdFile in env.Glob(os.path.join('addon', 'doc', '*', '*.md')): - # we don't process md templates - if mdFile.path.endswith(".tpl.md"): - continue - htmlFile = env.markdown(mdFile) - env.Depends(htmlFile, mdFile) - env.Depends(addon, htmlFile) + 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 +if (cssFile := Path("style.css")).is_file(): + cssPath = docsDir / cssFile + cssTarget = env.Command(str(cssPath), str(cssFile), Copy("$TARGET", "$SOURCE")) + env.Depends(addon, cssTarget) + +# Keep support for root README.tpl.md/readme.tpl.md used by this repository. +rootReadmeTemplate = next( + (p for p in (Path("README.tpl.md"), Path("readme.tpl.md")) if p.is_file()), + None, +) +if rootReadmeTemplate is not None: + rootReadme = _expandTemplateMarkdown(rootReadmeTemplate, env["addon_info"]) +else: + rootReadme = next((p for p in (Path("README.md"), Path("readme.md")) if p.is_file()), None) + +if rootReadme is not None: + # Keep add-on docs filename stable regardless of root README case. + readmePath = docsDir / buildVars.baseLanguage / "readme.md" + readmeTarget = env.Command(str(readmePath), str(rootReadme), Copy("$TARGET", "$SOURCE")) + env.Depends(addon, readmeTarget) + +if (rootContributing := Path("contributing.md")).is_file(): + contributingPath = docsDir / buildVars.baseLanguage / "contributing.md" + contributingTarget = env.Command( + str(contributingPath), + str(rootContributing), + Copy("$TARGET", "$SOURCE"), + ) + env.Depends(addon, contributingTarget) + +# Keep support for localized *.tpl.md docs. +for tplMd in env.Glob(docsDir / "*/*.tpl.md"): + _expandTemplateMarkdown(Path(str(tplMd)), env["addon_info"]) + +for mdFile in env.Glob(docsDir / "*/*.md"): + if str(mdFile).endswith(".tpl.md"): + continue + # 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/en/']) +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..3c313a9 --- /dev/null +++ b/site_scons/site_tools/NVDATool/__init__.py @@ -0,0 +1,96 @@ +""" +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 + +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 Builder, Environment + +from .addon import createAddonBundleFromPath +from .docs import md2html +from .manifests import generateManifest, generateTranslatedManifest + + +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={}) + + 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"], + ) + 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"], + ) + 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..9466478 --- /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..89ed6bb --- /dev/null +++ b/site_scons/site_tools/NVDATool/docs.py @@ -0,0 +1,58 @@ +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) + docText = "\n".join( + ( + "", + f'', + "", + '', + '', + '', + f"{title}", + "\n", + htmlText, + "\n", + ) + ) + with dest.open("w", encoding="utf-8") as f: + f.write(docText) diff --git a/site_scons/site_tools/NVDATool/manifests.py b/site_scons/site_tools/NVDATool/manifests.py new file mode 100644 index 0000000..5fb888f --- /dev/null +++ b/site_scons/site_tools/NVDATool/manifests.py @@ -0,0 +1,60 @@ +import codecs +import gettext +from functools import partial + +from .typings import AddonInfo, BrailleTables, SymbolDictionaries +from .utils import format_nested_section + + +def generateManifest( + source: str, + dest: str, + addon_info: AddonInfo, + brailleTables: BrailleTables, + symbolDictionaries: SymbolDictionaries, +): + with codecs.open(source, "r", "utf-8") as f: + manifest_template = f.read() + manifest = manifest_template.format(**addon_info) + if brailleTables: + manifest += format_nested_section("brailleTables", brailleTables) + + if symbolDictionaries: + manifest += format_nested_section("symbolDictionaries", symbolDictionaries) + + 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, +): + 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",), + _=_, + ) + + if brailleTables: + manifest += _format_section_only_with_displayName("brailleTables", brailleTables) + + if symbolDictionaries: + manifest += _format_section_only_with_displayName("symbolDictionaries", symbolDictionaries) + + 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..a140e8a --- /dev/null +++ b/site_scons/site_tools/NVDATool/typings.py @@ -0,0 +1,38 @@ +from typing import Protocol, TypedDict + + +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 + + +BrailleTables = dict[str, BrailleTableAttributes] +SymbolDictionaries = dict[str, SymbolDictionaryAttributes] + + +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..dbe7731 --- /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 db9a009..69a05d5 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,13 +15,12 @@ """ -from SCons.Action import Action +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' " @@ -30,26 +29,26 @@ def exists(env): "-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") + suffix=".pot", + ) - env['BUILDERS']['gettextMergePotFile'] = env.Builder( + env["BUILDERS"]["gettextMergePotFile"] = env.Builder( action=Action( "xgettext " + "--omit-header --no-location " + XGETTEXT_COMMON_ARGS, - "Generating pot file $TARGET" + "Generating pot file $TARGET", ), - suffix=".pot" + suffix=".pot", )