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/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..0ae3480 --- /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 master branch + id: differences-to-master + run: | + git fetch origin master --depth=1 + if git diff origin/master --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 master + if: steps.differences-to-master.outputs.changes_exist == 'true' + uses: actions/github-script@v7 + with: + script: | + core.setFailed('Releases can be generated only from commit on head of master 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/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..24dcf22 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,12 @@ +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.3.0 + hooks: + - id: check-ast + - id: check-case-conflict + - id: check-yaml + - repo: https://github.com/PyCQA/flake8 + rev: 7.1.1 + hooks: + - id: flake8 + args: [--config=flake8.ini] diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..514e061 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,13 @@ +{ + // See https://go.microsoft.com/fwlink/?LinkId=827846 to learn about workspace recommendations. + // Extension identifier format: ${publisher}.${name}. Example: vscode.csharp + // List of extensions which should be recommended for users of this workspace. + "recommendations": [ + "ms-python.python", + "ms-python.vscode-pylance", + "redhat.vscode-yaml", + "ms-python.flake8" + ], + // List of extensions recommended by VS Code that should not be recommended for users of this workspace. + "unwantedRecommendations": [] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..fb27c9e --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,20 @@ +{ + "editor.accessibilitySupport": "on", + "flake8.args": [ + "--config=${workspaceFolder}/flake8.ini" + ], + "flake8.importStrategy": "fromEnvironment", + "python.autoComplete.extraPaths": [ + "../nvda/source", + "../nvda/miscDeps/python" + ], + "files.insertFinalNewline": true, + "files.trimFinalNewlines": true, + "editor.insertSpaces": false, + "python.analysis.stubPath": "${workspaceFolder}/.vscode/typings", + "python.analysis.extraPaths": [ + "../nvda/source", + "../nvda/miscDeps/python" + ], + "python.defaultInterpreterPath": "${workspaceFolder}/../nvda/.venv/scripts/python.exe" +} diff --git a/.vscode/typings/__builtins__.pyi b/.vscode/typings/__builtins__.pyi new file mode 100644 index 0000000..88febec --- /dev/null +++ b/.vscode/typings/__builtins__.pyi @@ -0,0 +1,6 @@ +def _(msg: str) -> str: + ... + + +def pgettext(context: str, message: str) -> str: + ... diff --git a/addon/doc/en/contributing.md b/addon/doc/en/contributing.md index 382857d..3ac22b2 100644 --- a/addon/doc/en/contributing.md +++ b/addon/doc/en/contributing.md @@ -2,13 +2,53 @@ ## 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\snippetsForNVDA, 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-2024.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 snippetsForNVDA 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.6 or above. +* python 3.13. * pip must be configured * scons (pip install scons) * markdown (pip install markdown) -* msgfmt utility. The easiest way of getting it is by installing git bash and choosing to include bash tools at command prompt +* 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. diff --git a/addon/globalPlugins/snippetsForNVDA/__init__.py b/addon/globalPlugins/snippetsForNVDA/__init__.py index 42f5968..21a47a0 100644 --- a/addon/globalPlugins/snippetsForNVDA/__init__.py +++ b/addon/globalPlugins/snippetsForNVDA/__init__.py @@ -12,19 +12,19 @@ # Fix for the Russian keyboard layout -- We can't use # keyboardInputGesture.fromName() because it triggers a LookupError CTRLV = keyboardHandler.KeyboardInputGesture( -{(17, False), (16, False)}, -86, - 0, -False + {(17, False), (16, False)}, + 86, + 0, + False ) # Fix compatibility with the new role constants introduced in NVDA 2022.1.""" try: - from controlTypes import Role - ROLE_EDITABLETEXT = Role.EDITABLETEXT - ROLE_DOCUMENT = Role.DOCUMENT + from controlTypes import Role + ROLE_EDITABLETEXT = Role.EDITABLETEXT + ROLE_DOCUMENT = Role.DOCUMENT except ImportError: - from controlTypes import ROLE_EDITABLETEXT, ROLE_DOCUMENT + from controlTypes import ROLE_EDITABLETEXT, ROLE_DOCUMENT # Save the NVDA translation function so that we can use it if we need it nvdaTranslation = _ @@ -33,80 +33,84 @@ # At this point, the _ (underscore) function will be rebound with the translations of our addon addonHandler.initTranslation() + class GlobalPlugin(globalPluginHandler.GlobalPlugin): - memory = {} - lastPressedKey = 0 - - def script_saveToMemory(self, gesture): - focus = api.getFocusObject() - textInfo = None - if focus.treeInterceptor is not None and not focus.treeInterceptor.passThrough: - textInfo = focus.treeInterceptor.makeTextInfo(textInfos.POSITION_SELECTION) - elif focus.windowClassName in ["AkelEditW"] or focus.role in [ROLE_EDITABLETEXT, ROLE_DOCUMENT]: - textInfo = focus.makeTextInfo(textInfos.POSITION_SELECTION) - if textInfo is not None: - text = textInfo.text - if len(text) > 0: - keyCode = str(gesture.vkCode) # Get which number the user pressed. - # The number will be in the range 48 to 57 inclusive, and we will use it to hash the data in the dictionary. - self.memory[keyCode] = text - # Translators: This message is displayed when text is saved on a memory slot. - ui.message(_("Saved")) - else: - ui.message(nvdaTranslation("No selection")) - - # Translators: the documentation of the save to memory slot command, displayed on the input help mode. - script_saveToMemory.__doc__ = _("""When pressed, this key saves the selected text to this memory slot.""") - - def script_speakAndCopyMemory(self, gesture): - keyCode = str(gesture.vkCode) - try: - data = self.memory[keyCode] - if getLastScriptRepeatCount() == 0: - ui.message(data) - self.lastPressedKey = keyCode - elif getLastScriptRepeatCount() == 1 and self.isLastPressedKey(keyCode): - api.copyToClip(data) - # Translators: The message displayed when the user pasted this memory slot to an edit field. - ui.message(_("Pasted {data}").format(data=data)) - self.lastPressedKey = 0 - # Paste the selected text - CTRLV.send() - else: - self.lastPressedKey = 0 - ui.message(data) - except KeyError: - # Translators: The message when the user checks a memory slot but there is no data in it - ui.message(_("No data at this position")) - self.lastPressedKey = 0 - - # Translators: the documentation of the speak and copy memory slot command, displayed on the input help mode. - script_speakAndCopyMemory.__doc__ = _("""Pressing this key combination once , the content of this memory slot will be spoken. -Pressing it twice quickly, the content of this memory slot will be pasted to the running application.""") - - def script_saveSnippets(self, gesture): - with open(os.path.join(config.getUserDefaultConfigPath(),"snippets.data"), "w") as file: - json.dump(self.memory, file) - ui.message(_("Snippets saved")) - - script_saveSnippets.__doc__ = _("""Save the snippets to be used later""") - - def script_loadSnippets(self, gesture): - with open(os.path.join(config.getUserDefaultConfigPath(),"snippets.data"), "r") as file: - self.memory = json.load(file) - ui.message(_("Snnipets loaded successfully")) - - script_loadSnippets.__doc__ = _("""Loads previously saved snippets""") - - def isLastPressedKey(self, keyCode): - return self.lastPressedKey == keyCode - - __gestures = {} - __gestures["kb:NVDA+ALT+S"] = "saveSnippets" - __gestures["kb:NVDA+ALT+L"] = "loadSnippets" - - # Maps all 10 numeric keyboard keys to the apropriate gesture. - # It was done this way to avoid code repetition and to facilitate adding more commands in the future. - for keyboardKey in range(10): - __gestures[f"kb:NVDA+CONTROL+{keyboardKey}"] = "saveToMemory" - __gestures[f"kb:NVDA+CONTROL+SHIFT+{keyboardKey}"] = "speakAndCopyMemory" + memory = {} + lastPressedKey = 0 + + def script_saveToMemory(self, gesture): + focus = api.getFocusObject() + textInfo = None + if focus.treeInterceptor is not None and not focus.treeInterceptor.passThrough: + textInfo = focus.treeInterceptor.makeTextInfo(textInfos.POSITION_SELECTION) + elif focus.windowClassName in ["AkelEditW"] or focus.role in [ROLE_EDITABLETEXT, ROLE_DOCUMENT]: + textInfo = focus.makeTextInfo(textInfos.POSITION_SELECTION) + if textInfo is not None: + text = textInfo.text + if len(text) > 0: + keyCode = str(gesture.vkCode) # Get which number the user pressed. + # The number will be in the range (48 to 57 inclusive), + # and we will use it to hash the data in the dictionary. + self.memory[keyCode] = text + # Translators: This message is displayed when text is saved on a memory slot. + ui.message(_("Saved")) + else: + ui.message(nvdaTranslation("No selection")) + + # Translators: the documentation of the save to memory slot command, displayed on the input help mode. + script_saveToMemory.__doc__ = _("""When pressed, this key saves the selected text to this memory slot.""") + + def script_speakAndCopyMemory(self, gesture): + keyCode = str(gesture.vkCode) + try: + data = self.memory[keyCode] + if getLastScriptRepeatCount() == 0: + ui.message(data) + self.lastPressedKey = keyCode + elif getLastScriptRepeatCount() == 1 and self.isLastPressedKey(keyCode): + api.copyToClip(data) + # Translators: The message displayed when the user pasted this memory slot to an edit field. + ui.message(_("Pasted {data}").format(data=data)) + self.lastPressedKey = 0 + # Paste the selected text + CTRLV.send() + else: + self.lastPressedKey = 0 + ui.message(data) + except KeyError: + # Translators: The message when the user checks a memory slot but there is no data in it + ui.message(_("No data at this position")) + self.lastPressedKey = 0 + + # Translators: the documentation of the speak and copy memory slot command, displayed on the input help mode. + script_speakAndCopyMemory.__doc__ = _("""Pressing this key combination once, +the content of this memory slot will be spoken. +Pressing it twice quickly, +the content of this memory slot will be pasted to the running application.""") + + def script_saveSnippets(self, gesture): + with open(os.path.join(config.getUserDefaultConfigPath(), "snippets.data"), "w") as file: + json.dump(self.memory, file) + ui.message(_("Snippets saved")) + + script_saveSnippets.__doc__ = _("""Save the snippets to be used later""") + + def script_loadSnippets(self, gesture): + with open(os.path.join(config.getUserDefaultConfigPath(), "snippets.data"), "r") as file: + self.memory = json.load(file) + ui.message(_("Snnipets loaded successfully")) + + script_loadSnippets.__doc__ = _("""Loads previously saved snippets""") + + def isLastPressedKey(self, keyCode): + return self.lastPressedKey == keyCode + + __gestures = {} + __gestures["kb:NVDA+ALT+S"] = "saveSnippets" + __gestures["kb:NVDA+ALT+L"] = "loadSnippets" + + # Maps all 10 numeric keyboard keys to the apropriate gesture. + # It was done this way to avoid code repetition and to facilitate adding more commands in the future. + for keyboardKey in range(10): + __gestures[f"kb:NVDA+CONTROL+{keyboardKey}"] = "saveToMemory" + __gestures[f"kb:NVDA+CONTROL+SHIFT+{keyboardKey}"] = "speakAndCopyMemory" diff --git a/addon/locale/pt_BR/LC_MESSAGES/nvda.po b/addon/locale/pt_BR/LC_MESSAGES/nvda.po index 408f583..f693c1e 100644 --- a/addon/locale/pt_BR/LC_MESSAGES/nvda.po +++ b/addon/locale/pt_BR/LC_MESSAGES/nvda.po @@ -44,10 +44,10 @@ msgstr "Não há dados nessa posição" #. Translators: the documentation of the speak and copy memory slot command, displayed on the input help mode. #: addon\globalPlugins\snippetsForNVDA\__init__.py:65 msgid "" -"Pressing this key combination once , the content of this memory slot will be " -"spoken.\n" -"Pressing it twice quickly, the content of this memory slot will be pasted to " -"the running application." +"Pressing this key combination once,\n" +"the content of this memory slot will be spoken.\n" +"Pressing it twice quickly,\n" +"the content of this memory slot will be pasted to the running application." msgstr "" "Pressionando essa combinação de teclas uma vez, o conteúdo dessa posição de " "memória será falado.\n" diff --git a/addon/locale/uk/LC_MESSAGES/nvda.po b/addon/locale/uk/LC_MESSAGES/nvda.po index 5d4f866..febb240 100644 --- a/addon/locale/uk/LC_MESSAGES/nvda.po +++ b/addon/locale/uk/LC_MESSAGES/nvda.po @@ -42,10 +42,10 @@ msgstr "У цій позиції немає даних" #. Translators: the documentation of the speak and copy memory slot command, displayed on the input help mode. #: addon\globalPlugins\snippetsForNVDA\__init__.py:72 msgid "" -"Pressing this key combination once , the content of this memory slot will be " -"spoken.\n" -"Pressing it twice quickly, the content of this memory slot will be pasted to " -"the running application." +"Pressing this key combination once,\n" +"the content of this memory slot will be spoken.\n" +"Pressing it twice quickly,\n" +"the content of this memory slot will be pasted to the running application." msgstr "" "Якщо натиснути цю комбінацію клавіш один раз, буде озвучено вміст цього " "слота пам’яті.\n" diff --git a/addon/locale/zh_CN/LC_MESSAGES/nvda.po b/addon/locale/zh_CN/LC_MESSAGES/nvda.po index fd5e651..a2521f5 100644 --- a/addon/locale/zh_CN/LC_MESSAGES/nvda.po +++ b/addon/locale/zh_CN/LC_MESSAGES/nvda.po @@ -42,10 +42,10 @@ msgstr "无数据" #. Translators: the documentation of the speak and copy memory slot command, displayed on the input help mode. #: addon\globalPlugins\snippetsForNVDA\__init__.py:65 msgid "" -"Pressing this key combination once , the content of this memory slot will be " -"spoken.\n" -"Pressing it twice quickly, the content of this memory slot will be pasted to " -"the running application." +"Pressing this key combination once,\n" +"the content of this memory slot will be spoken.\n" +"Pressing it twice quickly,\n" +"the content of this memory slot will be pasted to the running application." msgstr "" "按一次,读出该暂存区的内容。\n" "连按两次,将该暂存区的内容粘贴到可输入文本的位置" diff --git a/buildVars.py b/buildVars.py index e86ca56..bba1679 100644 --- a/buildVars.py +++ b/buildVars.py @@ -3,8 +3,9 @@ # Build customizations # Change this file instead of sconstruct or manifest files, whenever possible. -# Full getext (please don't change) -_ = lambda x : x +from site_scons.site_tools.NVDATool.typings import AddonInfo, BrailleTables, SymbolDictionaries +from site_scons.site_tools.NVDATool.utils import _ + # Add-on information variables addon_info = { @@ -25,6 +26,8 @@ Press NVDA+CONTROL+SHIFT+numeric keys twice quickly to paste the content of this memory slot to the running application. Press NVDA+ALT+S to save the snippets to disk. Press NVDA+ALT+L to load the previously saved snippets from disk."""), + # Translators: what's new text for this add-on version shown in add-on store. + "addon_changelog": _("""NVDA 2026.1 compatibility."""), # version "addon_version" : "1.0.11", # Author(s) @@ -41,17 +44,17 @@ # 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, } +pythonSources: list[str] = ["addon/globalPlugins/snippetsForNVDA/*.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", +] -import os.path - -# Define the python files that are the sources of your add-on. -# You can use glob expressions here, they will be expanded. -pythonSources = [os.path.join("addon", "globalPlugins", "snippetsForNVDA", "*.py")] - -# Files that contain strings for translation. Usually your python sources -i18nSources = pythonSources + ["buildVars.py"] +baseLanguage: str = "en" +markdownExtensions: list[str] = [] -# 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 = [os.path.join("addon", "doc", "*", "contributing*.*")] +brailleTables: BrailleTables = {} +symbolDictionaries: SymbolDictionaries = {} diff --git a/contributing.md b/contributing.md index 382857d..3ac22b2 100644 --- a/contributing.md +++ b/contributing.md @@ -2,13 +2,53 @@ ## 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\snippetsForNVDA, 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-2024.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 snippetsForNVDA 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.6 or above. +* python 3.13. * pip must be configured * scons (pip install scons) * markdown (pip install markdown) -* msgfmt utility. The easiest way of getting it is by installing git bash and choosing to include bash tools at command prompt +* 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. diff --git a/flake8.ini b/flake8.ini new file mode 100644 index 0000000..d40e408 --- /dev/null +++ b/flake8.ini @@ -0,0 +1,31 @@ +# Custom Flake8 configuration for community add-on template +# Based on NVDA's Flake8 configuration with modifications for the basic add-on template (edited by Joseph Lee) + +[flake8] + +# Reporting +statistics = True +doctests = True +show-source = True + +# Options +max-complexity = 15 +max-line-length = 110 +# Final bracket should match indentation of the start of the line of the opening bracket +hang-closing = False + +ignore = + W191, + W503, + +builtins = # inform flake8 about functions we consider built-in. + _, + ngettext, + pgettext, + npgettext, + +exclude = # don't bother looking in the following subdirectories / files. + .git, + __pycache__, + buildVars.py, + site_scons/* diff --git a/localdeploy.bat b/localdeploy.bat new file mode 100644 index 0000000..94b6536 --- /dev/null +++ b/localdeploy.bat @@ -0,0 +1,5 @@ +@echo off +rem obtaining addon name, the current directory name +for %%f in (%cd%) do set addon=%%~nxf +rem copying addon files to local nvda addon directory +robocopy /S addon %appdata%\nvda\addons\%addon% \ No newline at end of file diff --git a/sconstruct b/sconstruct index aebaea2..8a09d74 100644 --- a/sconstruct +++ b/sconstruct @@ -1,228 +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) - if os.path.isfile("readme.tpl.md"): - expandFile("readme.tpl.md") - readmePath = os.path.join(docsDir, "en", "readme.md") - readmeTarget = env.Command(readmePath, "readme.md", Copy("$TARGET", "$SOURCE")) - env.Depends(addon, readmeTarget) - - 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 fa3a937..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,6 +15,7 @@ """ + from SCons.Action import Action def exists(env): @@ -24,6 +25,7 @@ def exists(env): "--msgid-bugs-address='$gettext_package_bugs_address' " "--package-name='$gettext_package_name' " "--package-version='$gettext_package_version' " + "--keyword=pgettext:1c,2 " "-c -o $TARGET $SOURCES" ) @@ -32,18 +34,21 @@ def generate(env): env.SetDefault(gettext_package_name="") env.SetDefault(gettext_package_version="") - env['BUILDERS']['gettextMoFile']=env.Builder( + env["BUILDERS"]["gettextMoFile"] = env.Builder( action=Action("msgfmt -o $TARGET $SOURCE", "Compiling translation $SOURCE"), suffix=".mo", - src_suffix=".po" + src_suffix=".po", ) - env['BUILDERS']['gettextPotFile']=env.Builder( + env["BUILDERS"]["gettextPotFile"] = env.Builder( action=Action("xgettext " + XGETTEXT_COMMON_ARGS, "Generating pot file $TARGET"), - suffix=".pot") - - env['BUILDERS']['gettextMergePotFile']=env.Builder( - action=Action("xgettext " + "--omit-header --no-location " + XGETTEXT_COMMON_ARGS, - "Generating pot file $TARGET"), - suffix=".pot") + suffix=".pot", + ) + env["BUILDERS"]["gettextMergePotFile"] = env.Builder( + action=Action( + "xgettext " + "--omit-header --no-location " + XGETTEXT_COMMON_ARGS, + "Generating pot file $TARGET", + ), + suffix=".pot", + )