From 5cbc1eca33aec552616c07cf46692bdc4fc07684 Mon Sep 17 00:00:00 2001 From: Marlon Sousa Date: Mon, 13 Jul 2026 18:05:39 -0300 Subject: [PATCH 1/2] Modernize build, fix mapping listing, persistence and edit bug Code fixes: * List every available app module (bundled with NVDA or provided by add-ons) by enumerating the appModules package, instead of only the handful that already have an alias in EXECUTABLE_NAMES_TO_APP_MODS. * Persist custom mappings in NVDA's user config directory (globalVars.appArgs.configPath) instead of the add-on folder, which is wiped on update/reinstall. Existing 0.2.0 files are migrated on load. * Fix add/modify detection in the mapping dialog (it was comparing the app name against the list of available modules) and preserve the true original module across edits so Remove restores it correctly. * Document the notAssociated app module and remove leftover dead code and the accidental a.txt. Build/tooling (ported from EnhancedDictionaries): * Typed scons NVDATool builders, cleaner gettexttool, new sconstruct, typed buildVars.py with changelog, manifest templates, flake8 and pre-commit updates. * Replace CI with reusable validate.yml plus pr-checks.yml and release.yml. Bump to 0.3.0; last tested against NVDA 2026.1. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/pr-checks.yml | 49 +++ .github/workflows/pr-validation.yml | 29 -- .github/workflows/release-addon.yml | 62 ---- .github/workflows/release.yml | 128 +++++++ .github/workflows/validate.yml | 60 ++++ .gitignore | 2 + .pre-commit-config.yaml | 2 +- README.md | 4 +- addon/appModules/notAssociated.py | 14 + addon/doc/en/contributing.md | 77 ++++ .../CustomAppModulesMapper/guiHelper.py | 19 +- .../CustomAppModulesMapper/mapperHandler.py | 71 +++- buildVars.py | 132 +++---- contributing.md | 77 ++++ flake8.ini | 28 +- manifest-translated.ini.tpl | 3 + manifest.ini.tpl | 1 + sconstruct | 332 ++++++++---------- site_scons/site_tools/NVDATool/__init__.py | 96 +++++ site_scons/site_tools/NVDATool/addon.py | 23 ++ site_scons/site_tools/NVDATool/docs.py | 58 +++ site_scons/site_tools/NVDATool/manifests.py | 60 ++++ site_scons/site_tools/NVDATool/typings.py | 38 ++ site_scons/site_tools/NVDATool/utils.py | 27 ++ site_scons/site_tools/gettexttool/__init__.py | 21 +- 25 files changed, 997 insertions(+), 416 deletions(-) create mode 100644 .github/workflows/pr-checks.yml delete mode 100644 .github/workflows/pr-validation.yml delete mode 100644 .github/workflows/release-addon.yml create mode 100644 .github/workflows/release.yml create mode 100644 .github/workflows/validate.yml create mode 100644 addon/appModules/notAssociated.py create mode 100644 addon/doc/en/contributing.md create mode 100644 contributing.md create mode 100644 manifest-translated.ini.tpl create mode 100644 site_scons/site_tools/NVDATool/__init__.py create mode 100644 site_scons/site_tools/NVDATool/addon.py create mode 100644 site_scons/site_tools/NVDATool/docs.py create mode 100644 site_scons/site_tools/NVDATool/manifests.py create mode 100644 site_scons/site_tools/NVDATool/typings.py create mode 100644 site_scons/site_tools/NVDATool/utils.py 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..566b890 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,128 @@ +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 + + - name: Submit to NVDA Add-on Store + id: submit-datastore + if: github.repository == 'marlon-sousa/CustomAppModulesMapper' + env: + GH_TOKEN: ${{ secrets.ADDON_DATASTORE_TOKEN }} + TAG_NAME: ${{ github.ref_name }} + REPO_URL: https://github.com/${{ github.repository }} + run: | + if [ -z "$GH_TOKEN" ]; then + echo "ADDON_DATASTORE_TOKEN is not configured; skipping submission." + { + echo "## NVDA Add-on Store Submission" + echo "- Skipped: ADDON_DATASTORE_TOKEN is not configured" + } >> "$GITHUB_STEP_SUMMARY" + exit 0 + fi + + ADDON_FILE=$(ls -1 *.nvda-addon | head -n 1) + if [ -z "$ADDON_FILE" ]; then + echo "No .nvda-addon file found." + exit 1 + fi + + DOWNLOAD_URL="${REPO_URL}/releases/download/${TAG_NAME}/${ADDON_FILE}" + + # Channel in add-on store submission form is stable/beta/dev. + # CustomAppModulesMapper uses stable releases for tagged builds. + CHANNEL="stable" + + ISSUE_TITLE="[Submit add-on]: ${ADDON_FILE}" + ISSUE_BODY=$(printf '### Download URL\n%s\n\n### Source URL\n%s\n\n### Publisher\n%s\n\n### Channel\n%s\n\n### License Name\n%s\n\n### License URL\n%s\n' \ + "$DOWNLOAD_URL" \ + "$REPO_URL" \ + "Marlon Sousa" \ + "$CHANNEL" \ + "Mit License" \ + "https://github.com/marlon-sousa/CustomAppModulesMapper/blob/main/LICENSE") + + ISSUE_URL=$(gh issue create \ + --repo nvaccess/addon-datastore \ + --title "$ISSUE_TITLE" \ + --body "$ISSUE_BODY") + + echo "Created datastore issue: ${ISSUE_URL}" + echo "addon_datastore_issue_url=${ISSUE_URL}" >> "$GITHUB_OUTPUT" + + { + echo "## NVDA Add-on Store Submission" + echo "- Datastore issue: ${ISSUE_URL}" + echo "- Release tag: ${TAG_NAME}" + echo "- Workflow run: ${REPO_URL}/actions/runs/${GITHUB_RUN_ID}" + } >> "$GITHUB_STEP_SUMMARY" 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", ) From 0a82a4a2dbc1b8fe91702cd88d45008ee20fab57 Mon Sep 17 00:00:00 2001 From: Marlon Sousa Date: Mon, 13 Jul 2026 18:23:55 -0300 Subject: [PATCH 2/2] Remove automatic NVDA add-on store submission from release workflow Releasing still builds and publishes the .nvda-addon to GitHub Releases; submission to the NV Access datastore is done manually. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/release.yml | 53 ----------------------------------- 1 file changed, 53 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 566b890..38ace77 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -73,56 +73,3 @@ jobs: body: "${{ steps.tag-data.outputs.git-tag-annotation }}" fail_on_unmatched_files: true generate_release_notes: false - - - name: Submit to NVDA Add-on Store - id: submit-datastore - if: github.repository == 'marlon-sousa/CustomAppModulesMapper' - env: - GH_TOKEN: ${{ secrets.ADDON_DATASTORE_TOKEN }} - TAG_NAME: ${{ github.ref_name }} - REPO_URL: https://github.com/${{ github.repository }} - run: | - if [ -z "$GH_TOKEN" ]; then - echo "ADDON_DATASTORE_TOKEN is not configured; skipping submission." - { - echo "## NVDA Add-on Store Submission" - echo "- Skipped: ADDON_DATASTORE_TOKEN is not configured" - } >> "$GITHUB_STEP_SUMMARY" - exit 0 - fi - - ADDON_FILE=$(ls -1 *.nvda-addon | head -n 1) - if [ -z "$ADDON_FILE" ]; then - echo "No .nvda-addon file found." - exit 1 - fi - - DOWNLOAD_URL="${REPO_URL}/releases/download/${TAG_NAME}/${ADDON_FILE}" - - # Channel in add-on store submission form is stable/beta/dev. - # CustomAppModulesMapper uses stable releases for tagged builds. - CHANNEL="stable" - - ISSUE_TITLE="[Submit add-on]: ${ADDON_FILE}" - ISSUE_BODY=$(printf '### Download URL\n%s\n\n### Source URL\n%s\n\n### Publisher\n%s\n\n### Channel\n%s\n\n### License Name\n%s\n\n### License URL\n%s\n' \ - "$DOWNLOAD_URL" \ - "$REPO_URL" \ - "Marlon Sousa" \ - "$CHANNEL" \ - "Mit License" \ - "https://github.com/marlon-sousa/CustomAppModulesMapper/blob/main/LICENSE") - - ISSUE_URL=$(gh issue create \ - --repo nvaccess/addon-datastore \ - --title "$ISSUE_TITLE" \ - --body "$ISSUE_BODY") - - echo "Created datastore issue: ${ISSUE_URL}" - echo "addon_datastore_issue_url=${ISSUE_URL}" >> "$GITHUB_OUTPUT" - - { - echo "## NVDA Add-on Store Submission" - echo "- Datastore issue: ${ISSUE_URL}" - echo "- Release tag: ${TAG_NAME}" - echo "- Workflow run: ${REPO_URL}/actions/runs/${GITHUB_RUN_ID}" - } >> "$GITHUB_STEP_SUMMARY"