diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 74c3c30..7793bb2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,7 +15,7 @@ jobs: container: image: ghcr.io/qgis/pyqgis4-checker:main-ubuntu steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Report Qt6 incompatibilities without modifying sources run: pyqt5_to_pyqt6.py --dry_run --logfile pyqt6_checker.log . @@ -26,7 +26,7 @@ jobs: git diff --exit-code - name: Upload checker report - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 if: always() with: name: pyqgis4-checker-report @@ -44,14 +44,17 @@ jobs: with: python-version: '3.9' - name: Install test tools - run: python -m pip install pytest flake8 bandit + run: python -m pip install --upgrade pip pytest flake8 bandit - name: Critical lint - run: flake8 . --select=E9,F63,F7,F82 + run: flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics + - name: Full lint + run: flake8 . --count --statistics - name: Offline tests - env: - PYTHONPATH: tests run: python -m pytest -q - name: Compile Python sources run: python -m compileall -q . - name: Bandit - run: bandit -r core gui translation_manager.py garmin_exporter.py --severity-level medium + run: >- + bandit -r core gui processing_provider translations qgis_compat.py + garmin_exporter.py translation_manager.py __init__.py + --severity-level medium diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..59bf12f --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,66 @@ +name: Build plugin release asset + +on: + push: + tags: + - "v*.*.*" + workflow_dispatch: + inputs: + version: + description: "Version without the leading v, for example 1.3.0" + required: true + type: string + +permissions: + contents: write + +jobs: + package: + name: Build plugin-only ZIP + runs-on: ubuntu-latest + steps: + - name: Check out release source + uses: actions/checkout@v4 + + - name: Set package version + id: version + shell: bash + run: | + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + version="${{ inputs.version }}" + else + version="${GITHUB_REF_NAME#v}" + fi + echo "version=$version" >> "$GITHUB_OUTPUT" + + - name: Build installable plugin ZIP + run: | + python scripts/build_plugin.py \ + --output "dist/garmin_export-${{ steps.version.outputs.version }}.zip" + + - name: Verify plugin-only ZIP + run: | + python scripts/verify_plugin_archive.py \ + "dist/garmin_export-${{ steps.version.outputs.version }}.zip" + + - name: Upload workflow artifact + uses: actions/upload-artifact@v4 + with: + name: garmin_export-${{ steps.version.outputs.version }} + path: dist/garmin_export-${{ steps.version.outputs.version }}.zip + if-no-files-found: error + + - name: Create or update GitHub release asset + env: + GH_TOKEN: ${{ github.token }} + shell: bash + run: | + tag="v${{ steps.version.outputs.version }}" + asset="dist/garmin_export-${{ steps.version.outputs.version }}.zip" + if gh release view "$tag" >/dev/null 2>&1; then + gh release upload "$tag" "$asset" --clobber + else + gh release create "$tag" "$asset" \ + --title "Garmin Export $tag" \ + --generate-notes + fi diff --git a/CITATION.cff b/CITATION.cff new file mode 100644 index 0000000..e98c63a --- /dev/null +++ b/CITATION.cff @@ -0,0 +1,30 @@ +cff-version: 1.2.0 +title: "Garmin Export" +message: "If you use Garmin Export, please cite it using this file." +type: software +authors: + - family-names: Kobyakov + given-names: Alexander + email: kobyakov@lesburo.ru + affiliation: Lesburo +version: 1.2.1 +date-released: 2026-08-24 +license: AGPL-3.0-or-later +repository-code: "https://github.com/AlexKobyakov/garmin_export" +url: "https://github.com/AlexKobyakov/garmin_export" +abstract: >- + A QGIS plugin for exporting vector layers to Garmin IMG maps through the + mkgmap compiler. The project supports QGIS 3.22+ with a shared Qt5/Qt6 + compatibility boundary, a localized 12-language UI, TYP styling and + cancellation-safe dependency and export workflows. +keywords: + - GIS + - QGIS + - Garmin IMG + - mkgmap + - vector data + - TYP styling + - Qt5 + - Qt6 + - PyQGIS + - map export diff --git a/README.md b/README.md index 82556d6..921f00f 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,19 @@ # Garmin Export Plugin для QGIS +[![CI](https://github.com/AlexKobyakov/garmin_export/actions/workflows/ci.yml/badge.svg)](https://github.com/AlexKobyakov/garmin_export/actions/workflows/ci.yml) +[![License: AGPLv3](https://img.shields.io/badge/License-AGPLv3-blue.svg)](LICENSE) +![QGIS 3.22+ / 4.x](https://img.shields.io/badge/QGIS-3.22%2B%20%2F%204.x-589632.svg) +![Qt5 + Qt6](https://img.shields.io/badge/Qt5%20%2B%20Qt6-compatible-informational.svg) +![Version](https://img.shields.io/badge/version-1.3.0-informational.svg) +![Tests](https://img.shields.io/badge/tests-167%20offline-success.svg) + 🎯 **Профессиональный плагин для экспорта векторных данных QGIS в формат Garmin IMG** -Версия: 1.2.1 +Версия: 1.3.0 Автор: Кобяков Александр Викторович (Alex Kobyakov) Email: kobyakov@lesburo.ru Год: 2025-2026 +Совместимость: QGIS 3.44/Qt5 и QGIS 4.2/Qt6 🌐 **Языки интерфейса / UI languages:** 🇷🇺 Русский · 🇺🇸 English · 🇩🇪 Deutsch · 🇪🇸 Español · 🇫🇷 Français · 🇧🇷 Português · 🇨🇳 中文 · 🇮🇳 हिन्दी · 🇸🇦 العربية · 🇮🇩 Bahasa Indonesia · 🇹🇭 ไทย · 🇻🇳 Tiếng Việt @@ -48,10 +56,56 @@ Garmin GPS devices. - 📋 **mkgmap logging**: optional mkgmap.log file with a configurable verbosity. - 💾 **Persistent settings** between sessions. - 🧾 **Reliable lifecycle**: cancellation-safe export runs, stale-worker isolation and an anonymized .garmin_export/run_manifest.json on every completed run. +- 🧰 **Processing Toolbox (G6)**: validate the environment, build TYP mappings, validate TYP/code pages and mapping JSON, diagnose/download dependencies, generate MP previews and export IMG through Processing with generated, existing or disabled TYP styling, selected levels and advanced mkgmap options. Existing complete mkgmap/splitter installations are auto-detected; network download is explicit via the AUTO_DOWNLOAD parameter and reuses the UI fallback order with feedback cancellation. +- 🖥️ **Qt5/Qt6 dual support**: one compatibility boundary for QGIS 3.44/Qt5 and QGIS 4.2/Qt6, with scoped enum fallbacks and no direct PyQt5/PyQt6 imports. +- 🎛️ **Polished UI contract**: live/restart-safe language switching, SVG flags, readable combo/check controls, retranslate-safe dialogs, translated tooltips, placeholders and actions. + + +### Processing Toolbox and QGIS Modeler + +The provider is available under **Processing → Toolbox → Garmin Export** and in +QGIS Modeler, Batch and the Python Processing API. It provides seven +GUI-independent algorithms: + +1. **Validate environment** — reports Java, mkgmap and splitter separately. +2. **Build TYP mapping** — creates TYP from QGIS layer symbology. +3. **Export selected layers to Garmin IMG** — selected layers or all valid + project vectors, generated/existing/default TYP, levels, code page and typed + mkgmap tuning; returns IMG plus a redacted run_manifest.json. +4. **Validate TYP/code page** — checks a TYP/TXT path and label encoding. +5. **Generate MP preview** — writes Polish MP without Java or mkgmap. +6. **Validate mapping JSON** — validates file/inline JSON, geometry, Garmin type + and level ranges. +7. **Dependency diagnostics/download** — discovers a complete local distribution + and downloads only when AUTO_DOWNLOAD is enabled. + +Parameter names are stable ASCII identifiers across QGIS 3.44/Qt5 and QGIS +4.2/Qt6. Existing complete distributions are auto-detected; network download +is opt-in and reuses the UI fallback order, transactional install and +cancellable feedback. + +QGIS can run many independent Processing algorithms as tasks, and Modeler can +chain validation → MP/TYP generation → export. QGIS controls task parallelism; +the provider does not create duplicate GUI workers. For parallel exports use a +distinct output directory, map id and temporary directory per task. + +Python console example: + + processing.run("garmin_export:validate_mapping_json", { + "MAPPING_FILE": r"C:\maps\mapping.json" + }) + processing.run("garmin_export:export_selected_layers", { + "USE_PROJECT_LAYERS": True, + "OUTPUT": r"C:\maps\out" + }) + +In Modeler, connect validation outputs to export inputs and expose OUTPUT, +AUTO_DOWNLOAD, code page, TYP mode and tuning as model inputs. ### Requirements -- QGIS 3.22 or newer, Python 3.9+ +- QGIS 3.22 or newer (Qt5 on QGIS 3.x; Qt6 on QGIS 4.x), Python 3.9+ +- Manually verified in QGIS 3.44/Qt5 and QGIS 4.2/Qt6. - Java Runtime Environment (JRE 8+) for mkgmap (the plugin can auto-detect it) ### Quick start @@ -94,11 +148,40 @@ Garmin Export Plugin - это современный инструмент для - 📋 **Логирование mkgmap**: опциональный файл журнала mkgmap.log с настраиваемым уровнем детализации - 💾 **Сохранение настроек** между сеансами работы - 🧾 **Надёжный жизненный цикл**: безопасная отмена, защита от устаревших worker-сигналов и обезличенный .garmin_export/run_manifest.json для каждого завершённого запуска +- 🧰 **Processing Toolbox (G6)**: проверка окружения, построение и проверка TYP, проверка кодовой страницы и mapping JSON, диагностика/загрузка зависимостей, MP-preview и экспорт IMG с выбором TYP, уровней карты и расширенных параметров mkgmap. Установленные дистрибутивы mkgmap/splitter с каталогом lib/ определяются автоматически; скачивание выполняется только при включённом параметре AUTO_DOWNLOAD и использует тот же порядок зеркал и отмену через feedback. + + +### Processing Toolbox и QGIS Modeler + +Провайдер доступен в **Обработка → Панель инструментов → Garmin Export**, а +также в QGIS Modeler, пакетном запуске и Python API Processing. Семь алгоритмов +не зависят от окна плагина: проверка окружения; построение TYP; экспорт IMG с +генерацией/существующим/отключённым TYP, уровнями, кодовой страницей и tuning; +проверка TYP и кодовой страницы; MP-preview; проверка mapping JSON; диагностика +и загрузка зависимостей. Экспорт возвращает обезличенный run_manifest.json. + +Имена параметров Processing — стабильные ASCII-идентификаторы для QGIS +3.44/Qt5 и QGIS 4.2/Qt6. Полные дистрибутивы определяются автоматически; +сеть включается только через AUTO_DOWNLOAD и использует порядок зеркал и +отмену из UI. + +QGIS может выполнять много независимых алгоритмов Processing как задачи, а +Modeler — связывать проверку -> генерацию MP/TYP -> экспорт. Параллелизм +контролирует QGIS. Для параллельных экспортов используйте разные выходные +каталоги, map id и временные папки. + +Пример в Python-консоли QGIS: + + processing.run("garmin_export:validate_mapping_json", {"MAPPING_FILE": r"C:\maps\mapping.json"}) + processing.run("garmin_export:export_selected_layers", {"USE_PROJECT_LAYERS": True, "OUTPUT": r"C:\maps\out"}) + +В Modeler соедините результаты проверок с экспортом и вынесите OUTPUT, +AUTO_DOWNLOAD, кодовую страницу, режим TYP и tuning в параметры модели. ## 🔧 Требования ### Обязательные: -- QGIS версии 3.22 или выше +- QGIS версии 3.22 или выше (Qt5 в QGIS 3.x; Qt6 в QGIS 4.x) - Python 3.9+ - Java Runtime Environment (JRE) для работы mkgmap diff --git a/__init__.py b/__init__.py index 1abc167..b8e997b 100644 --- a/__init__.py +++ b/__init__.py @@ -7,7 +7,7 @@ Email: kobyakov@lesburo.ru Organization: Lesburo Year: 2025-2026 -Version: 1.1.4 +Version: 1.2.1 Modular Architecture: - garmin_exporter.py: Main plugin class diff --git a/core/export_service.py b/core/export_service.py index f0f60b1..799253a 100644 --- a/core/export_service.py +++ b/core/export_service.py @@ -42,7 +42,7 @@ def _tool_version(path, tool): class ExportService: """Owns one run's state; it never imports QGIS or emits GUI signals.""" - def __init__(self, selected_layers, settings, plugin_version='1.1.4'): + def __init__(self, selected_layers, settings, plugin_version='1.2.1'): self.selected_layers = [ {'id': str(item.get('id') or ''), 'name': str(item.get('name') or '')} diff --git a/docs/GITHUB_REPOSITORY_PROFILE.md b/docs/GITHUB_REPOSITORY_PROFILE.md new file mode 100644 index 0000000..a1c2e1e --- /dev/null +++ b/docs/GITHUB_REPOSITORY_PROFILE.md @@ -0,0 +1,17 @@ +# GitHub repository profile + +The repository About panel is remote GitHub metadata and is not stored in the +working tree. Recommended values: + +- Description: QGIS plugin for exporting vector layers to Garmin IMG maps via mkgmap; Qt5/Qt6, 12-language UI, TYP styling, Processing Toolbox/Modeler algorithms and reliable cancellation-safe workflows. +- Homepage: https://github.com/AlexKobyakov/garmin_export +- Topics: qgis, qgis-plugin, pyqgis, garmin, garmin-img, mkgmap, gis, vector-data, map-export, qt5, qt6, typ, multilingual + +After authenticating GitHub CLI, apply them with: + +~~~bash +gh repo edit AlexKobyakov/garmin_export --description "QGIS plugin for exporting vector layers to Garmin IMG maps via mkgmap; Qt5/Qt6, 12-language UI, TYP styling, Processing Toolbox/Modeler algorithms and reliable cancellation-safe workflows." --homepage "https://github.com/AlexKobyakov/garmin_export" --add-topic qgis --add-topic qgis-plugin --add-topic pyqgis --add-topic garmin --add-topic garmin-img --add-topic mkgmap --add-topic gis --add-topic vector-data --add-topic map-export --add-topic qt5 --add-topic qt6 --add-topic typ --add-topic processing --add-topic qgis-modeler --add-topic multilingual +~~~ + +This command requires an authenticated gh auth login; no credentials are +stored in the repository. diff --git a/docs/RELEASE_1.2.1_CHECKLIST.md b/docs/RELEASE_1.2.1_CHECKLIST.md index a4d660f..3cf483c 100644 --- a/docs/RELEASE_1.2.1_CHECKLIST.md +++ b/docs/RELEASE_1.2.1_CHECKLIST.md @@ -2,10 +2,26 @@ ## Scope +The 1.2.1 release includes the previously planned 1.2.0 modernization scope. +There is no separate 1.2.0 tag or archive: the Qt5/Qt6 boundary, UI contract, +bounded GUI refactor and 12-language/i18n work were completed and released +together with the G4/G5 reliability work as 1.2.1. + +The carried scope includes: + +- shared QGIS 3/Qt5 and QGIS 4/Qt6 compatibility helpers and checker guards; +- retranslate-safe composite widgets/dialogs, retained layout references, + translated tooltips/placeholders/actions and readable combo/check controls; +- 12-language registry, SVG flags, Arabic RTL, live/restart persistence and + translation parity tests; +- production Python modules bounded to 500 lines or less. + + Release 1.2.1 closes the bounded G0-G5 track. It contains the Qt5/Qt6 boundary, UI and translation contract, 12-language flags/RTL support, dependency -reliability and the cancellation-safe export lifecycle. Processing Provider and -other feature work are outside this release. +reliability and the cancellation-safe export lifecycle. Processing Provider work is tracked as the post-1.2.1 G6 feature scope; +the current branch contains the implementation and owner smoke confirmation, +The Processing scope was released separately as 1.3.0; see RELEASE_1.3.0_CHECKLIST.md. ## Runtime prerequisites @@ -33,7 +49,7 @@ contain wheels/, plan/, tests/ or docs/. ## Offline release gates -- pytest -q: 155 passed. +- pytest -q: 167 passed (current branch baseline). - Critical and full Flake8: passed. - compileall: passed. - Bandit: passed. @@ -44,3 +60,33 @@ contain wheels/, plan/, tests/ or docs/. - metadata.txt version: 1.2.1. - Git tag: v1.2.1. - Build command: python scripts/build_plugin.py --output dist/garmin_export-1.2.1.zip. + +## Post-1.2.1 Processing smoke + +For the G6 feature scope, repeat in QGIS 3.44/Qt5 and QGIS 4.2/Qt6: + +1. Confirm the Garmin Export group and plugin icon in Processing -> Toolbox. +2. Run environment validation with valid and invalid Java/mkgmap/splitter paths. +3. Validate mapping JSON, build TYP, and generate an MP preview. +4. Run IMG export with generated, existing and disabled TYP modes, levels, + code page and advanced tuning. +5. Run dependency diagnostics with AUTO_DOWNLOAD off and on only when network + access is intended. +6. Check STATUS, ERRORS, MANIFEST and cancellation behavior. +7. Open the algorithms in Modeler and run a small batch with distinct outputs. + +The owner confirmed the QGIS 3.44 and 4.2 smoke matrix for the current branch. + +## Release asset policy + +The installable release asset must be built by scripts/build_plugin.py, then +checked by scripts/verify_plugin_archive.py. The GitHub workflow +.github/workflows/release.yml performs both steps for vX.Y.Z tags and uploads +garmin_export-X.Y.Z.zip. + +That ZIP contains only the garmin_export/ plugin tree: runtime Python modules, +resources, translations, metadata.txt, LICENSE and the icon. It excludes +repository documentation, tests, CI files, build scripts, examples, wheels, +planning files and development caches. A GitHub-generated Source code (zip) +snapshot is a repository archive, not the installable plugin package; users +must download the named garmin_export-X.Y.Z.zip release asset. diff --git a/docs/RELEASE_1.3.0_CHECKLIST.md b/docs/RELEASE_1.3.0_CHECKLIST.md new file mode 100644 index 0000000..2f8ca2a --- /dev/null +++ b/docs/RELEASE_1.3.0_CHECKLIST.md @@ -0,0 +1,31 @@ +# Garmin Export 1.3.0 — release checklist + +## Scope + +Release 1.3.0 delivers the G6 Processing Provider and G7 release hardening: + +- seven algorithms under Processing → Toolbox → Garmin Export; +- QGIS Modeler, Batch and processing.run() support with stable ASCII parameter IDs; +- TYP modes, code-page and mapping JSON validation, MP preview; +- dependency diagnostics/download with opt-in network access and auto-discovery; +- typed mkgmap tuning, project-layer batch export and explicit run manifest output; +- plugin-only release ZIP packaging and the Qt6 checker rewrite/diff guard. + +## Release gates + +- Owner smoke confirmed QGIS 3.44/Qt5 and QGIS 4.2/Qt6. +- pytest: 167 passed. +- Full flake8, compileall and Bandit: passed. +- Build and verify: + + python scripts/build_plugin.py --output dist/garmin_export-1.3.0.zip + python scripts/verify_plugin_archive.py dist/garmin_export-1.3.0.zip + +The verified ZIP must contain only the garmin_export/ plugin tree and must not +contain docs, tests, plan, wheels, scripts, CI files, examples or JAR files. + +## GitHub publication + +Create tag v1.3.0 through the normal repository workflow. The release workflow +builds and uploads the named asset garmin_export-1.3.0.zip. Users must download +that asset rather than GitHub's automatically generated Source code archive. diff --git a/docs/install.md b/docs/install.md index 14b6102..94f2b58 100644 --- a/docs/install.md +++ b/docs/install.md @@ -222,3 +222,20 @@ settings or secrets. Для mkgmap и splitter нужен полный ZIP-дистрибутив, а не одиночный JAR: рядом с главным JAR должен находиться каталог lib/. Плагин проверяет это условие до установки и оставляет прежнюю рабочую версию при ошибке. + +## 🧰 Проверка Processing Toolbox + +После активации проверьте **Обработка → Панель инструментов → Garmin Export**. +Если группа не видна, перезапустите QGIS и убедитесь, что +hasProcessingProvider=yes есть в metadata.txt. Должны быть семь алгоритмов, +включая Validate mapping JSON и Dependency diagnostics/download. + +Validate environment с AUTO_DOWNLOAD выключенным проверяет Java и автоматически +ищет полный mkgmap/splitter в профиле QGIS. Загрузка запускается отдельно и +только после явного включения AUTO_DOWNLOAD. Export selected layers принимает +выбранные или все проектные векторные слои и возвращает IMG плюс MANIFEST. + +QGIS Modeler и Batch могут выполнять независимые ветви параллельно; задавайте +разные output/map id и временные каталоги. В скриптах используйте +processing.run("garmin_export:", {...}); имена параметров +одинаковы во всех 12 языках. diff --git a/docs/user_guide.md b/docs/user_guide.md index 9ff99f2..313b3c3 100644 --- a/docs/user_guide.md +++ b/docs/user_guide.md @@ -10,6 +10,7 @@ 6. [Процесс компиляции](#процесс-компиляции) 7. [Примеры использования](#примеры-использования) 8. [Советы и рекомендации](#советы-и-рекомендации) +9. [Processing Toolbox и Modeler](#-processing-toolbox-и-modeler) ## 🖥️ Обзор интерфейса @@ -279,3 +280,31 @@ Garmin использует 4 уровня детализации для опт результате. После запуска проверяйте файл .garmin_export/run_manifest.json в выходной папке: он содержит обезличенные параметры запуска, версии инструментов и размер результата только при успехе. + +## 🧰 Processing Toolbox и Modeler + +После активации группа **Garmin Export** появляется в **Обработка → +Панель инструментов**. Доступны семь алгоритмов: Validate environment, Build +TYP mapping, Export selected layers, Validate TYP/code page, Generate MP +preview, Validate mapping JSON и Dependency diagnostics/download. + +Провайдер поддерживает выбранные слои или все векторные слои проекта, +генерируемый/существующий/отключённый TYP, уровни, кодовую страницу, +типизированный tuning и явный выход MANIFEST +(.garmin_export/run_manifest.json). Полные mkgmap/splitter с lib/ находятся +автоматически; скачивание выполняется только при AUTO_DOWNLOAD. + +В Modeler соберите цепочку Validate mapping JSON -> Build TYP mapping или +Generate MP preview -> Export selected layers. QGIS может выполнять независимые +ветви Processing и пакетные задачи параллельно; число задач контролирует QGIS. +Для одновременных экспортов используйте разные output, map id и временные +каталоги. Имена параметров остаются стабильными ASCII-ключами для QGIS 3.44 +и 4.2. + +Пример: + + processing.run("garmin_export:validate_mapping_json", {"MAPPING_FILE": r"C:\maps\mapping.json"}) + processing.run("garmin_export:export_selected_layers", {"USE_PROJECT_LAYERS": True, "OUTPUT": r"C:\maps\out"}) + +В Modeler вынесите OUTPUT, AUTO_DOWNLOAD, кодовую страницу, режим TYP и tuning +в параметры модели и проверяйте STATUS, ERRORS и MANIFEST. diff --git a/garmin_exporter.py b/garmin_exporter.py index 6afebd8..0859a7e 100644 --- a/garmin_exporter.py +++ b/garmin_exporter.py @@ -43,6 +43,7 @@ def __init__(self, iface): self.menu = PLUGIN_NAME self.dialog = None self.main_action = None + self.processing_provider = None self.first_start = True # ------------------------------------------------------------------ @@ -73,6 +74,13 @@ def get_plugin_info(): 'author': 'Кобяков Александр Викторович', 'email': 'kobyakov@lesburo.ru', 'description': '', + 'qgis_minimum': '', + 'qgis_maximum': '', + 'has_processing_provider': '', + 'repository': '', + 'homepage': '', + 'tags': '', + 'changelog': '', } try: plugin_dir = os.path.dirname(__file__) @@ -88,6 +96,14 @@ def get_plugin_info(): 'author': section.get('author', default['author']), 'email': section.get('email', default['email']), 'description': section.get('description', ''), + 'qgis_minimum': section.get('qgisMinimumVersion', ''), + 'qgis_maximum': section.get('qgisMaximumVersion', ''), + 'has_processing_provider': section.get( + 'hasProcessingProvider', ''), + 'repository': section.get('repository', ''), + 'homepage': section.get('homepage', ''), + 'tags': section.get('tags', ''), + 'changelog': section.get('changelog', ''), } except Exception as e: print(f"Error reading plugin info: {e}") @@ -134,8 +150,31 @@ def initGui(self): whats_this=translations.get_text('plugin_description') ) + self._register_processing_provider() + self.first_start = True + def _register_processing_provider(self): + """Register the provider once, including after plugin reloads.""" + from .processing_provider import GarminProcessingProvider + + registry = QgsApplication.processingRegistry() + provider = registry.providerById('garmin_export') + if provider is None: + provider = GarminProcessingProvider() + registry.addProvider(provider) + self.processing_provider = provider + + def _unregister_processing_provider(self): + """Remove only the provider owned by this plugin instance.""" + provider = self.processing_provider + if provider is None: + return + registry = QgsApplication.processingRegistry() + if registry.providerById(provider.id()) is provider: + registry.removeProvider(provider.id()) + self.processing_provider = None + def retranslateUi(self): """Обновляет текст QAction после live-переключения языка.""" if self.main_action is None: @@ -148,6 +187,7 @@ def retranslateUi(self): def unload(self): """Удаление элементов GUI при выгрузке плагина""" + self._unregister_processing_provider() for action in self.actions: self.iface.removePluginVectorMenu(self.menu, action) self.iface.removeToolBarIcon(action) diff --git a/gui/gui_dialogs.py b/gui/gui_dialogs.py index 1424488..e84f4a3 100644 --- a/gui/gui_dialogs.py +++ b/gui/gui_dialogs.py @@ -11,8 +11,11 @@ Year: 2025-2026 """ +import html import json +import os +from qgis.PyQt.QtCore import QFileSystemWatcher from qgis.PyQt.QtWidgets import ( QMessageBox, QDialog, QVBoxLayout, QHBoxLayout, QLabel, QTextEdit, QGroupBox @@ -39,14 +42,28 @@ def __init__(self, parent=None): 'author': 'Кобяков Александр Викторович', 'email': 'kobyakov@lesburo.ru', } - self.setMinimumSize(560, 640) - self.resize(560, 640) + self.setMinimumSize(560, 760) + self.resize(560, 760) self.setModal(True) self.setWindowFlags( qt_enum('WindowType', 'Dialog') | qt_enum('WindowType', 'WindowTitleHint') | qt_enum('WindowType', 'WindowCloseButtonHint')) self.setupUi() + self._watch_metadata() + self.retranslateUi() + + def _watch_metadata(self): + # Refresh the metadata card when metadata.txt changes on disk. + path = os.path.join(os.path.dirname(os.path.dirname(__file__)), + 'metadata.txt') + self._metadata_path = path + self._metadata_watcher = QFileSystemWatcher([path], self) + self._metadata_watcher.fileChanged.connect(self._metadata_changed) + + def _metadata_changed(self, path): + if os.path.exists(path) and path not in self._metadata_watcher.files(): + self._metadata_watcher.addPath(path) self.retranslateUi() def setupUi(self): @@ -80,6 +97,14 @@ def setupUi(self): self.contact_label.setOpenExternalLinks(True) self.contact_label.setStyleSheet(self._card('#f8f9fa', '#dee2e6')) + self.metadata_label = QTextEdit() + self.metadata_label.setReadOnly(True) + self.metadata_label.setMinimumHeight(150) + self.metadata_label.setMaximumHeight(230) + self.metadata_label.setStyleSheet( + 'background-color: #f8f9fa; border: 1px solid #dee2e6; ' + 'border-radius: 8px; padding: 15px; color: #2c3e50;') + self.close_button = create_styled_button(t('close'), 'secondary', '✖️') self.close_button.clicked.connect(self.accept) @@ -88,6 +113,7 @@ def setupUi(self): layout.addWidget(self.version_label) layout.addWidget(self.about_label) layout.addWidget(self.contact_label) + layout.addWidget(self.metadata_label) layout.addStretch() layout.addWidget( self.close_button, 0, qt_enum('AlignmentFlag', 'AlignCenter')) @@ -96,6 +122,11 @@ def setupUi(self): def retranslateUi(self): t = translations.get_text + try: + from ..garmin_exporter import GarminExporter + self._info = GarminExporter.get_plugin_info() + except Exception: + pass info = self._info self.setWindowTitle( '{0} — {1}'.format(PLUGIN_NAME, t('header_about_author'))) @@ -116,6 +147,34 @@ def retranslateUi(self): contact_l=t('contact'), email=info['email'], org_l=t('organization'), year_l=t('year'), multi=t('multilingual_support'))) + minimum = html.escape(info.get('qgis_minimum', '') or '?') + maximum = html.escape(info.get('qgis_maximum', '') or '?') + changelog = html.escape(info.get('changelog', '').strip()) + changelog = changelog.replace('\n', '
') + repository = html.escape(info.get('repository', '') or '') + tags = html.escape(info.get('tags', '') or '') + self.metadata_label.setHtml( + '📦 {metadata}
' + '🧩 {version}: v{plugin_version}
' + '🗺️ {qgis}: {minimum} – {maximum}
' + '⚙️ {processing}: {provider}
' + '🔖 {tags_label}: {tags}
' + '🔗 {repository}

' + '{changelog_label}
{changelog}'.format( + metadata=t('metadata_details'), + version=t('version'), + plugin_version=html.escape(info.get('version', 'Unknown')), + qgis=t('qgis_support'), + minimum=minimum, + maximum=maximum, + processing=t('processing_provider'), + provider=html.escape( + info.get('has_processing_provider', '') or 'no'), + tags_label=t('tags'), + tags=tags, + repository=repository, + changelog_label=t('changelog'), + changelog=changelog or '—')) self.close_button.setText('✖️ ' + t('close')) @staticmethod diff --git a/metadata.txt b/metadata.txt index 01581b9..17d5a1d 100644 --- a/metadata.txt +++ b/metadata.txt @@ -2,14 +2,28 @@ name=Garmin Export qgisMinimumVersion=3.22 qgisMaximumVersion=4.99 -description=Professional export tool for converting QGIS vector data to Garmin IMG format via mkgmap -version=1.2.1 +hasProcessingProvider=yes +description=Professional Garmin IMG export for QGIS 3.22+ with Qt5/Qt6 dual support, modern localized UI, 12 languages and reliable mkgmap workflows +version=1.3.0 author=Alex Kobyakov email=kobyakov@lesburo.ru -about=Professional Garmin Export tool for converting QGIS vector layers to Garmin IMG format using the mkgmap compiler. Features: one-click download of the full mkgmap/splitter distributions (zip archives with all dependency libraries; QGIS forbids bundling jars), automatic TYP styling generated from QGIS layer symbology, JSON-based type mapping, multi-level map support, advanced mkgmap tuning and logging options, comprehensive label code-page support (UTF-8 plus Windows/DOS/CJK pages), batch processing of all project layers, and a 12-language interface (EN, RU, DE, ES, FR, PT, ZH, HI, AR, ID, TH, VI). Requires Java (JRE 8+) which the plugin can auto-detect. +about=Professional Garmin Export tool with a Processing Toolbox provider for QGIS Modeler and batch workflows, converting QGIS vector layers to Garmin IMG format using the mkgmap compiler. Supports QGIS 3.44/Qt5 and QGIS 4.2/Qt6 through a shared compatibility boundary. The UI provides live/restart-safe translation, SVG language flags, RTL Arabic, readable combo/check controls, retranslate-safe dialogs, tooltips and actions. Features: one-click download of the full mkgmap/splitter distributions (zip archives with all dependency libraries; QGIS forbids bundling jars), automatic TYP styling generated from QGIS layer symbology, JSON-based type mapping, multi-level map support, advanced mkgmap tuning and logging options, comprehensive label code-page support (UTF-8 plus Windows/DOS/CJK pages), batch processing of all project layers, and a 12-language interface (EN, RU, DE, ES, FR, PT, ZH, HI, AR, ID, TH, VI). Requires Java (JRE 8+) which the plugin can auto-detect. tracker=https://github.com/AlexKobyakov/garmin_export/issues repository=https://github.com/AlexKobyakov/garmin_export -changelog=1.2.1 - G4/G5 reliability release +changelog=1.3.0 - Processing Toolbox and release hardening + - Added the QGIS Processing Provider with seven stable algorithms for + environment, TYP, MP, mapping JSON, dependency and IMG workflows. + - Added typed mkgmap tuning, project-layer batch export, TYP modes, + code-page validation and explicit redacted run_manifest output. + - Added opt-in dependency diagnostics/download with auto-discovery and + the same transactional, language-aware fallback order as the UI. + - Added Processing Toolbox, Modeler and batch documentation for QGIS + 3.44/Qt5 and 4.2/Qt6, including parallel-task output isolation. + - Added plugin-only release packaging with archive validation and a + GitHub workflow that uploads garmin_export-X.Y.Z.zip assets. + - Added the Qt6 checker rewrite/diff guard and 167 offline regression + tests; owner smoke confirmed QGIS 3.44 and QGIS 4.2. + 1.2.1 - G4/G5 reliability release - Added transactional mkgmap/splitter ZIP installation with staging, lock, cancellation cleanup, last-good preservation and required lib/ validation - Added language-aware fallback sources: Russian Yandex.Disk -> GitHub -> @@ -22,6 +36,16 @@ changelog=1.2.1 - G4/G5 reliability release correctness in the export worker - Added offline G0-G5 regression coverage; release archive excludes plan, tests and wheels while retaining wheels in the public source repository + 1.2.0 (scope delivered in 1.2.1) - Qt5/Qt6, UI and localization modernization + - Added the shared QGIS 3/4 and Qt5/Qt6 compatibility boundary with scoped + enum fallbacks and static/official checker guards. + - Reworked the UI contract: composite retranslateUi passes, retained form/table + references, translated tooltips/placeholders/actions, stable combo data and + readable Qt5/Qt6 popup/check controls. + - Completed bounded GUI decomposition under the <=500-line production-file rule. + - Added the 12-language registry, SVG flags, Arabic RTL, live switching, + restart persistence and translation key/placeholder parity guards. + 1.1.4 - Security scan fix (QGIS plugin repository) - Restrict downloads to http/https via a dedicated URL opener without file/ftp/data handlers, resolving the Bandit B310 critical finding (CWE-22) @@ -55,7 +79,7 @@ changelog=1.2.1 - G4/G5 reliability release - Author/Support dialogs identical to reference plugin - Offline unit tests for core logic 1.0.0 - Initial release -tags=garmin, export, img, mkgmap, gps, vector, typ, batch, multilingual, navigation, custom-maps +tags=garmin, export, img, mkgmap, gps, qgis, vector, typ, batch, multilingual, navigation, custom-maps, qt5, qt6, processing, openstreetmap homepage=https://github.com/AlexKobyakov/garmin_export category=Vector icon=icon.png diff --git a/processing_provider/__init__.py b/processing_provider/__init__.py new file mode 100644 index 0000000..22b8ddb --- /dev/null +++ b/processing_provider/__init__.py @@ -0,0 +1,6 @@ +# -*- coding: utf-8 -*- +"""QGIS Processing provider for Garmin Export.""" + +from .provider import GarminProcessingProvider + +__all__ = ['GarminProcessingProvider'] diff --git a/processing_provider/algorithms.py b/processing_provider/algorithms.py new file mode 100644 index 0000000..e9f3850 --- /dev/null +++ b/processing_provider/algorithms.py @@ -0,0 +1,432 @@ +# -*- coding: utf-8 -*- +"""Stable Garmin Export Processing algorithms. + +The provider is deliberately independent from the dialog. Processing owns +parameters, feedback, cancellation and output paths; the existing export +worker remains the single implementation of the full IMG workflow. +""" + +import os + +from qgis.core import ( + QgsProcessing, QgsProcessingAlgorithm, QgsProcessingException, + QgsProcessingParameterBoolean, QgsProcessingParameterFile, + QgsProcessingParameterDefinition, + QgsProcessingParameterFileDestination, QgsProcessingParameterMultipleLayers, + QgsProcessingParameterNumber, QgsProcessingParameterString, + QgsProcessingParameterEnum, + QgsProcessingOutputString, +) + +from ..core.export_worker import ExportWorker +from ..core.layer_manager import LayerManager +from ..core.mkgmap_compiler import java_diagnostics +from ..core.style_mapper import StyleMapper +from ..core.codepages import CODE_PAGE_CODES +from ..core import typ_generator +from ..qgis_compat import qgis_enum +from .runtime import ensure_tool + + +def _number(owner, name, description, default, minimum=0, integer=True): + kind = qgis_enum( + 'ProcessingNumberParameterType', 'Integer' if integer else 'Double', + owner) + return QgsProcessingParameterNumber( + name, description, kind, defaultValue=default, minValue=minimum) + + +def _optional(parameter): + flag = qgis_enum( + 'ProcessingParameterFlag', 'Optional', + QgsProcessingParameterDefinition, 'FlagOptional') + parameter.setFlags(parameter.flags() | flag) + return parameter + + +def _optional_file(name, description): + parameter = QgsProcessingParameterFile(name, description) + parameter.setIsDynamic(False) + return _optional(parameter) + + +def _vector_type(): + return qgis_enum( + 'ProcessingSourceType', 'VectorAnyGeometry', + QgsProcessing, 'TypeVectorAnyGeometry') + + +def _parse_levels(value): + try: + levels = sorted({ + int(item.strip()) for item in str(value or '').split(',') + if item.strip()}) + except (TypeError, ValueError): + raise QgsProcessingException('levels_invalid') + if not levels or any(level < 0 or level > 7 for level in levels): + raise QgsProcessingException('levels_invalid') + return levels + + +def _validate_code_page(value): + code_page = str(value or '').strip() + if code_page not in CODE_PAGE_CODES: + raise QgsProcessingException('code_page_invalid') + return code_page + + +def _project_layers(): + return [entry['layer'] for entry in LayerManager.get_project_layers() + if entry.get('layer') is not None] + + +class _BaseAlgorithm(QgsProcessingAlgorithm): + """Shared labels and machine-stable Processing group.""" + + def group(self): + return 'Garmin Export' + + def groupId(self): + return 'garmin_export' + + def shortHelpString(self): + return 'Garmin Export Processing workflow; paths and IDs are stable.' + + +class ValidateEnvironmentAlgorithm(_BaseAlgorithm): + """Validate Java and the complete mkgmap/splitter distributions.""" + + def name(self): + return 'validate_environment' + + def displayName(self): + return 'Validate Garmin environment' + + def initAlgorithm(self, config=None): + mkgmap = QgsProcessingParameterFile( + 'MKGMP', 'mkgmap.jar (with adjacent lib directory)') + self.addParameter(_optional(mkgmap)) + self.addParameter(_optional_file('SPLITTER', + 'splitter.jar (with adjacent lib)')) + self.addParameter(_optional_file('JAVA', 'Java executable')) + self.addParameter(QgsProcessingParameterBoolean( + 'AUTO_DOWNLOAD', 'Download mkgmap when missing', defaultValue=False)) + self.addParameter(QgsProcessingParameterString( + 'MIN_JAVA', 'Minimum Java major version', defaultValue='8')) + self.addOutput(QgsProcessingOutputString('STATUS', 'Validation status')) + self.addOutput(QgsProcessingOutputString('MKGMP_PATH', 'mkgmap path')) + self.addOutput(QgsProcessingOutputString('SPLITTER_PATH', 'splitter path')) + self.addOutput(QgsProcessingOutputString('JAVA_PATH', 'Java path')) + self.addOutput(QgsProcessingOutputString( + 'MKGMP_STATUS', 'mkgmap status')) + self.addOutput(QgsProcessingOutputString( + 'SPLITTER_STATUS', 'splitter status')) + self.addOutput(QgsProcessingOutputString('JAVA_STATUS', 'Java status')) + + def processAlgorithm(self, parameters, context, feedback): + mkgmap = self.parameterAsFile(parameters, 'MKGMP', context) + splitter = self.parameterAsFile(parameters, 'SPLITTER', context) + java = self.parameterAsFile(parameters, 'JAVA', context) + auto_download = self.parameterAsBool( + parameters, 'AUTO_DOWNLOAD', context) + try: + mkgmap = ensure_tool( + 'mkgmap', mkgmap, auto_download, feedback, start=0, span=45) + if splitter: + splitter = ensure_tool( + 'splitter', splitter, auto_download, feedback, + start=45, span=25) + except ValueError as exc: + raise QgsProcessingException(str(exc)) + try: + minimum = int(self.parameterAsString(parameters, 'MIN_JAVA', context) or 8) + except ValueError: + raise QgsProcessingException('java_minimum_invalid') + diagnostics = java_diagnostics(java or None, min_major=minimum) + if not diagnostics.get('ok'): + raise QgsProcessingException('java_{0}'.format(diagnostics.get('code'))) + splitter_status = 'not_requested' + if splitter: + splitter_status = 'valid' + feedback.pushInfo('mkgmap, splitter and Java environment are valid') + feedback.setProgress(100) + return { + 'STATUS': 'ok', + 'MKGMP_PATH': mkgmap, + 'SPLITTER_PATH': splitter or '', + 'JAVA_PATH': diagnostics.get('path') or '', + 'MKGMP_STATUS': 'valid', + 'SPLITTER_STATUS': splitter_status, + 'JAVA_STATUS': 'valid', + } + + def createInstance(self): + return ValidateEnvironmentAlgorithm() + + +class BuildTypMappingAlgorithm(_BaseAlgorithm): + """Create a TYP file from selected layer symbology.""" + + def name(self): + return 'build_typ_mapping' + + def displayName(self): + return 'Build TYP mapping from layers' + + def initAlgorithm(self, config=None): + self.addParameter(QgsProcessingParameterMultipleLayers( + 'LAYERS', 'Vector layers', _vector_type())) + self.addParameter(_number(QgsProcessingParameterNumber, 'FID', + 'Family ID', 1234, 1)) + self.addParameter(_number(QgsProcessingParameterNumber, 'PRODUCT_ID', + 'Product ID', 1, 1)) + self.addParameter(QgsProcessingParameterString( + 'CODE_PAGE', 'Code page', defaultValue='1252')) + self.addParameter(QgsProcessingParameterFileDestination( + 'OUTPUT', 'TYP output', 'Text files (*.txt);;All files (*.*)')) + + def processAlgorithm(self, parameters, context, feedback): + layers = self.parameterAsLayerList(parameters, 'LAYERS', context) + if not layers: + raise QgsProcessingException('layers_empty') + mapper = StyleMapper() + entries = [] + for index, layer in enumerate(layers): + if feedback.isCanceled(): + raise QgsProcessingException('cancelled') + geometry = LayerManager.get_geometry_type(layer) + if geometry == 'Unknown': + raise QgsProcessingException('geometry_unsupported') + mapping = mapper.get_layer_mapping(layer.name(), geometry) + entries.append({ + 'layer': layer, + 'name': layer.name(), + 'geometry_type': geometry, + 'garmin_type': mapping.get('type', '0x01'), + }) + feedback.setProgress((index + 1) * 80.0 / len(layers)) + code_page = _validate_code_page( + self.parameterAsString(parameters, 'CODE_PAGE', context) or '1252') + text = typ_generator.build_typ_from_layers( + entries, + self.parameterAsInt(parameters, 'FID', context), + self.parameterAsInt(parameters, 'PRODUCT_ID', context), + code_page) + output = self.parameterAsFileOutput(parameters, 'OUTPUT', context) + if not output: + raise QgsProcessingException('output_missing') + try: + with open(output, 'w', encoding='utf-8') as handle: + handle.write(text) + except OSError as exc: + raise QgsProcessingException('output_write_failed: {0}'.format(exc)) + feedback.setProgress(100) + return {'OUTPUT': output} + + def createInstance(self): + return BuildTypMappingAlgorithm() + + +class ExportSelectedLayersAlgorithm(_BaseAlgorithm): + """Run the existing export worker synchronously through Processing feedback.""" + + def name(self): + return 'export_selected_layers' + + def displayName(self): + return 'Export selected layers to Garmin IMG' + + def initAlgorithm(self, config=None): + layers_parameter = QgsProcessingParameterMultipleLayers( + 'LAYERS', 'Vector layers', _vector_type()) + self.addParameter(_optional(layers_parameter)) + self.addParameter(QgsProcessingParameterBoolean( + 'USE_PROJECT_LAYERS', 'Use all valid project vector layers', + defaultValue=False)) + mkgmap = QgsProcessingParameterFile( + 'MKGMP', 'mkgmap.jar (with adjacent lib directory)') + self.addParameter(_optional(mkgmap)) + self.addParameter(_optional_file('JAVA', 'Java executable')) + self.addParameter(QgsProcessingParameterBoolean( + 'AUTO_DOWNLOAD', 'Download mkgmap when missing', defaultValue=False)) + self.addParameter(_number(QgsProcessingParameterNumber, 'FID', + 'Family ID', 1234, 1)) + self.addParameter(_number(QgsProcessingParameterNumber, 'MAP_ID', + 'Map ID', 1, 1)) + for name, label, default in ( + ('MAP_NAME', 'Map name', 'Garmin Map'), + ('MAP_DESCRIPTION', 'Map description', ''), + ('CODE_PAGE', 'Code page', '1252')): + self.addParameter(QgsProcessingParameterString( + name, label, defaultValue=default)) + self.addParameter(QgsProcessingParameterBoolean( + 'TRANSPARENT', 'Transparent map', defaultValue=False)) + self.addParameter(QgsProcessingParameterBoolean( + 'ROUTING', 'Enable routing', defaultValue=False)) + for name, label, default in ( + ('INDEX', 'Enable address index', False), + ('ADD_POIS_TO_AREAS', 'Add POIs to areas', False), + ('ORDER_BY_DECREASING_AREA', 'Order polygons by area', False), + ('LOWER_CASE', 'Use lower-case labels', False), + ('MKGMAP_LOGGING', 'Write mkgmap log', False), + ('MKGMAP_VERBOSE', 'Verbose mkgmap log', False), + ('KEEP_TEMP_FILES', 'Keep intermediate files', False)): + self.addParameter(QgsProcessingParameterBoolean( + name, label, defaultValue=default)) + self.addParameter(_number( + QgsProcessingParameterNumber, 'DRAW_PRIORITY', + 'Draw priority', 25, 0)) + self.addParameter(_number( + QgsProcessingParameterNumber, 'MIN_SIZE_POLYGON', + 'Minimum polygon size', 8, 0)) + self.addParameter(_number( + QgsProcessingParameterNumber, 'MAX_JOBS', + 'Maximum mkgmap jobs (0=auto)', 0, 0)) + for name, label in ( + ('REDUCE_POINT_DENSITY', 'Reduce point density'), + ('REDUCE_POINT_DENSITY_POLYGON', + 'Reduce polygon point density')): + self.addParameter(_number( + QgsProcessingParameterNumber, name, label, 0.0, 0.0, + integer=False)) + self.addParameter(_number( + QgsProcessingParameterNumber, 'JAVA_HEAP_GB', + 'Java heap limit in GB (0=auto)', 0.0, 0.0, integer=False)) + self.addParameter(QgsProcessingParameterString( + 'EXTRA_ARGS', 'Additional mkgmap arguments', defaultValue='')) + self.addParameter(QgsProcessingParameterEnum( + 'TYP_MODE', 'TYP styling', + options=['Generate from layer styles', + 'Use existing TYP/TXT file', + 'Garmin default (no TYP)'], + defaultValue=0)) + self.addParameter(_optional_file( + 'TYP_FILE', 'Existing TYP/TXT file (for TYP_MODE=1)')) + self.addParameter(QgsProcessingParameterString( + 'ENABLED_LEVELS', 'Enabled levels (comma-separated)', + defaultValue='0,1,2,3')) + self.addParameter(QgsProcessingParameterFileDestination( + 'OUTPUT', 'IMG output', 'Garmin IMG (*.img);;All files (*.*)')) + self.addOutput(QgsProcessingOutputString( + 'MANIFEST', 'Redacted run manifest path')) + + def processAlgorithm(self, parameters, context, feedback): + layers = self.parameterAsLayerList(parameters, 'LAYERS', context) + if self.parameterAsBool(parameters, 'USE_PROJECT_LAYERS', context): + layers = _project_layers() + output = self.parameterAsFileOutput(parameters, 'OUTPUT', context) + mkgmap = self.parameterAsFile(parameters, 'MKGMP', context) + if not layers: + raise QgsProcessingException('layers_empty') + if not output: + raise QgsProcessingException('output_missing') + auto_download = self.parameterAsBool( + parameters, 'AUTO_DOWNLOAD', context) + try: + mkgmap = ensure_tool( + 'mkgmap', mkgmap, auto_download, feedback, start=0, span=25) + except ValueError as exc: + raise QgsProcessingException(str(exc)) + typ_mode_index = self.parameterAsInt(parameters, 'TYP_MODE', context) + typ_modes = ('generate', 'file', 'none') + if typ_mode_index not in range(len(typ_modes)): + raise QgsProcessingException('typ_mode_invalid') + typ_file = self.parameterAsFile(parameters, 'TYP_FILE', context) + if typ_modes[typ_mode_index] == 'file' and ( + not typ_file or not os.path.isfile(typ_file)): + raise QgsProcessingException('typ_file_missing') + code_page = _validate_code_page( + self.parameterAsString(parameters, 'CODE_PAGE', context) or '1252') + enabled_levels = _parse_levels( + self.parameterAsString(parameters, 'ENABLED_LEVELS', context) + or '0,1,2,3') + folder = os.path.dirname(output) or os.getcwd() + os.makedirs(folder, exist_ok=True) + selected = [{'id': layer.id(), 'name': layer.name()} for layer in layers] + filename = os.path.splitext(os.path.basename(output))[0] + settings = { + 'output_folder': folder, + 'output_filename': filename, + 'mkgmap_path': mkgmap, + 'java_path': self.parameterAsFile(parameters, 'JAVA', context), + 'family_id': self.parameterAsInt(parameters, 'FID', context), + 'map_id': self.parameterAsInt(parameters, 'MAP_ID', context), + 'map_name': self.parameterAsString(parameters, 'MAP_NAME', context), + 'map_description': self.parameterAsString( + parameters, 'MAP_DESCRIPTION', context), + 'code_page': code_page, + 'transparent': self.parameterAsBool(parameters, 'TRANSPARENT', context), + 'routing': self.parameterAsBool(parameters, 'ROUTING', context), + 'mkgmap_logging': self.parameterAsBool( + parameters, 'MKGMAP_LOGGING', context), + 'mkgmap_verbose': self.parameterAsBool( + parameters, 'MKGMAP_VERBOSE', context), + 'keep_temp_files': self.parameterAsBool( + parameters, 'KEEP_TEMP_FILES', context), + 'enabled_levels': enabled_levels, + 'typ_mode': typ_modes[typ_mode_index], + 'typ_file_path': typ_file or '', + 'mkgmap_options': { + 'index': self.parameterAsBool(parameters, 'INDEX', context), + 'add_pois_to_areas': self.parameterAsBool( + parameters, 'ADD_POIS_TO_AREAS', context), + 'order_by_decreasing_area': self.parameterAsBool( + parameters, 'ORDER_BY_DECREASING_AREA', context), + 'lower_case': self.parameterAsBool( + parameters, 'LOWER_CASE', context), + 'draw_priority': self.parameterAsInt( + parameters, 'DRAW_PRIORITY', context), + 'min_size_polygon': self.parameterAsInt( + parameters, 'MIN_SIZE_POLYGON', context), + 'max_jobs': self.parameterAsInt( + parameters, 'MAX_JOBS', context), + 'reduce_point_density': self.parameterAsDouble( + parameters, 'REDUCE_POINT_DENSITY', context) or None, + 'reduce_point_density_polygon': self.parameterAsDouble( + parameters, 'REDUCE_POINT_DENSITY_POLYGON', context) or None, + 'java_xmx': self._java_heap( + parameters, context), + 'extra_args': self.parameterAsString( + parameters, 'EXTRA_ARGS', context), + }, + } + worker = ExportWorker(selected, settings) + result = {'success': False, 'path': '', 'error': ''} + + def progress(value, message): + feedback.setProgress(value) + feedback.pushInfo(str(message)) + if feedback.isCanceled(): + worker.stop() + + def log(message): + feedback.pushInfo(str(message)) + if feedback.isCanceled(): + worker.stop() + + def finished(success, path): + result.update(success=bool(success), path=path or '') + + def failed(message): + result['error'] = str(message) + + worker.progress.connect(progress) + worker.log_message.connect(log) + worker.finished.connect(finished) + worker.error.connect(failed) + worker.run() + if feedback.isCanceled() or not result['success']: + raise QgsProcessingException( + 'cancelled' if feedback.isCanceled() else + 'export_failed: {0}'.format(result['error'] or 'unknown')) + feedback.setProgress(100) + manifest = os.path.join( + folder, '.garmin_export', 'run_manifest.json') + return {'OUTPUT': result['path'], 'MANIFEST': manifest} + + def _java_heap(self, parameters, context): + value = self.parameterAsDouble(parameters, 'JAVA_HEAP_GB', context) + return '{0:g}g'.format(value) if value > 0 else '' + + def createInstance(self): + return ExportSelectedLayersAlgorithm() diff --git a/processing_provider/extra_algorithms.py b/processing_provider/extra_algorithms.py new file mode 100644 index 0000000..619148f --- /dev/null +++ b/processing_provider/extra_algorithms.py @@ -0,0 +1,290 @@ +# -*- coding: utf-8 -*- +"""Bounded Processing algorithms that do not invoke mkgmap.""" + +import json + +from qgis.core import ( + QgsProcessingParameterBoolean, QgsProcessingParameterEnum, + QgsProcessingParameterFileDestination, QgsProcessingParameterMultipleLayers, + QgsProcessingParameterString, QgsProcessingOutputString, + QgsProcessingException, +) + +from ..core.layer_processor import LayerProcessor +from ..core.mp_generator import MPGenerator +from ..core.style_mapper import StyleMapper +from .algorithms import ( + _BaseAlgorithm, _optional, _optional_file, _parse_levels, + _project_layers, _validate_code_page, _vector_type, +) +from .runtime import discover_tool, ensure_tool +from ..core.downloader import tool_manifest + + +class ValidateTypCodePageAlgorithm(_BaseAlgorithm): + """Validate a Garmin code page and an optional TYP/TXT file.""" + + def name(self): + return 'validate_typ_code_page' + + def displayName(self): + return 'Validate TYP and code page' + + def initAlgorithm(self, config=None): + self.addParameter(QgsProcessingParameterString( + 'CODE_PAGE', 'Code page', defaultValue='1252')) + self.addParameter(_optional_file('TYP_FILE', 'TYP/TXT file')) + self.addParameter(QgsProcessingParameterBoolean( + 'REQUIRE_TYP', 'Require a TYP file', defaultValue=False)) + self.addOutput(QgsProcessingOutputString( + 'CODE_PAGE_STATUS', 'Code page status')) + self.addOutput(QgsProcessingOutputString( + 'TYP_STATUS', 'TYP status')) + + def processAlgorithm(self, parameters, context, feedback): + code_page = _validate_code_page( + self.parameterAsString(parameters, 'CODE_PAGE', context) or '1252') + typ_path = self.parameterAsFile(parameters, 'TYP_FILE', context) + required = self.parameterAsBool(parameters, 'REQUIRE_TYP', context) + if not typ_path: + if required: + raise QgsProcessingException('typ_file_missing') + typ_status = 'not_requested' + else: + if not typ_path.lower().endswith(('.typ', '.txt')): + raise QgsProcessingException('typ_file_extension_invalid') + try: + with open(typ_path, encoding='utf-8', errors='replace') as stream: + content = stream.read(4096) + except OSError as exc: + raise QgsProcessingException('typ_file_unreadable: {0}'.format(exc)) + if not content.strip() or '[' not in content: + raise QgsProcessingException('typ_file_invalid') + typ_status = 'valid' + feedback.pushInfo('Code page {0} and TYP {1}'.format( + code_page, typ_status)) + feedback.setProgress(100) + return { + 'CODE_PAGE_STATUS': 'valid:{0}'.format(code_page), + 'TYP_STATUS': typ_status, + } + + def createInstance(self): + return ValidateTypCodePageAlgorithm() + + +class GenerateMpPreviewAlgorithm(_BaseAlgorithm): + """Generate an MP preview without requiring Java or mkgmap.""" + + def name(self): + return 'generate_mp_preview' + + def displayName(self): + return 'Generate MP preview' + + def initAlgorithm(self, config=None): + layers = QgsProcessingParameterMultipleLayers( + 'LAYERS', 'Vector layers', _vector_type()) + self.addParameter(_optional(layers)) + self.addParameter(QgsProcessingParameterBoolean( + 'USE_PROJECT_LAYERS', 'Use all valid project vector layers', + defaultValue=False)) + self.addParameter(QgsProcessingParameterString( + 'MAP_NAME', 'Map name', defaultValue='Garmin Map')) + self.addParameter(QgsProcessingParameterString( + 'MAP_DESCRIPTION', 'Map description', defaultValue='')) + self.addParameter(QgsProcessingParameterString( + 'CODE_PAGE', 'Code page', defaultValue='1252')) + self.addParameter(QgsProcessingParameterString( + 'ENABLED_LEVELS', 'Enabled levels (comma-separated)', + defaultValue='0,1,2,3')) + self.addParameter(QgsProcessingParameterFileDestination( + 'OUTPUT', 'MP output', 'Polish MP (*.mp);;All files (*.*)')) + + def processAlgorithm(self, parameters, context, feedback): + layers = self.parameterAsLayerList(parameters, 'LAYERS', context) + if self.parameterAsBool(parameters, 'USE_PROJECT_LAYERS', context): + layers = _project_layers() + if not layers: + raise QgsProcessingException('layers_empty') + code_page = _validate_code_page( + self.parameterAsString(parameters, 'CODE_PAGE', context) or '1252') + levels = _parse_levels( + self.parameterAsString(parameters, 'ENABLED_LEVELS', context) + or '0,1,2,3') + output = self.parameterAsFileOutput(parameters, 'OUTPUT', context) + if not output: + raise QgsProcessingException('output_missing') + processor = LayerProcessor() + mapper = StyleMapper() + processed = [] + for index, layer in enumerate(layers): + if feedback.isCanceled(): + raise QgsProcessingException('cancelled') + try: + processed.append(processor.process_layer( + layer, mapper, levels, + log_callback=feedback.pushWarning)) + except Exception as exc: + raise QgsProcessingException( + 'layer_failed:{0}'.format(exc)) + feedback.setProgress((index + 1) * 80.0 / len(layers)) + try: + MPGenerator().generate_mp_file( + processed, output, { + 'map_id': 1, + 'map_name': self.parameterAsString( + parameters, 'MAP_NAME', context), + 'map_description': self.parameterAsString( + parameters, 'MAP_DESCRIPTION', context), + 'code_page': code_page, + 'enabled_levels': levels, + 'transparent': False, + 'routing': False, + }) + except OSError as exc: + raise QgsProcessingException('output_write_failed: {0}'.format(exc)) + feedback.setProgress(100) + return {'OUTPUT': output} + + def createInstance(self): + return GenerateMpPreviewAlgorithm() + + +class ValidateMappingJsonAlgorithm(_BaseAlgorithm): + """Validate the mapping JSON used by the export worker.""" + + def name(self): + return 'validate_mapping_json' + + def displayName(self): + return 'Validate mapping JSON' + + def initAlgorithm(self, config=None): + self.addParameter(_optional_file( + 'MAPPING_FILE', 'Mapping JSON file')) + self.addParameter(_optional(QgsProcessingParameterString( + 'MAPPING_JSON', 'Mapping JSON text', defaultValue=''))) + self.addOutput(QgsProcessingOutputString('STATUS', 'Mapping status')) + self.addOutput(QgsProcessingOutputString( + 'ENTRY_COUNT', 'Mapping entry count')) + self.addOutput(QgsProcessingOutputString('ERRORS', 'Validation errors')) + + @staticmethod + def _validate(data): + errors = [] + if not isinstance(data, dict): + return ['root must be an object'] + layers = data.get('layers') + if not isinstance(layers, dict) or not layers: + return ['layers must be a non-empty object'] + geometries = {'Point', 'POI', 'point', 'LineString', 'Line', + 'Polyline', 'line', 'Polygon', 'Area', 'polygon', 'area'} + for name, mapping in layers.items(): + if not isinstance(name, str) or not name.strip(): + errors.append('layer name must be non-empty') + continue + if not isinstance(mapping, dict): + errors.append('{0}: mapping must be an object'.format(name)) + continue + if mapping.get('geometry') not in geometries: + errors.append('{0}: invalid geometry'.format(name)) + type_value = mapping.get('type') + try: + if not isinstance(type_value, str) or not type_value.lower().startswith('0x'): + raise ValueError + value = int(type_value, 16) + if value < 0 or value > 0xffff: + raise ValueError + except (TypeError, ValueError): + errors.append('{0}: invalid Garmin type'.format(name)) + if 'level' in mapping: + try: + level = int(mapping['level']) + if level < 0 or level > 7: + raise ValueError + except (TypeError, ValueError): + errors.append('{0}: invalid level'.format(name)) + return errors + + def processAlgorithm(self, parameters, context, feedback): + file_path = self.parameterAsFile(parameters, 'MAPPING_FILE', context) + text = self.parameterAsString(parameters, 'MAPPING_JSON', context) or '' + if file_path: + try: + with open(file_path, encoding='utf-8') as stream: + text = stream.read() + except OSError as exc: + raise QgsProcessingException( + 'mapping_file_unreadable: {0}'.format(exc)) + if not text.strip(): + raise QgsProcessingException('mapping_input_missing') + try: + data = json.loads(text) + except json.JSONDecodeError as exc: + raise QgsProcessingException( + 'mapping_json_invalid:{0}'.format(exc)) + errors = self._validate(data) + if errors: + raise QgsProcessingException('mapping_invalid:' + '; '.join(errors)) + feedback.setProgress(100) + return { + 'STATUS': 'valid', + 'ENTRY_COUNT': str(len(data['layers'])), + 'ERRORS': '', + } + + def createInstance(self): + return ValidateMappingJsonAlgorithm() + + +class DependencyDiagnosticsDownloadAlgorithm(_BaseAlgorithm): + """Diagnose or explicitly install one complete Java tool distribution.""" + + def name(self): + return 'dependency_diagnostics_download' + + def displayName(self): + return 'Diagnose/download Garmin dependency' + + def initAlgorithm(self, config=None): + self.addParameter(QgsProcessingParameterEnum( + 'TOOL', 'Tool', options=['mkgmap', 'splitter'], defaultValue=0)) + self.addParameter(_optional_file('JAR_PATH', 'Existing tool JAR')) + self.addParameter(QgsProcessingParameterBoolean( + 'AUTO_DOWNLOAD', 'Download when missing', defaultValue=False)) + self.addOutput(QgsProcessingOutputString('STATUS', 'Dependency status')) + self.addOutput(QgsProcessingOutputString('PATH', 'Resolved JAR path')) + self.addOutput(QgsProcessingOutputString('DETAILS', 'Dependency details')) + + def processAlgorithm(self, parameters, context, feedback): + index = self.parameterAsInt(parameters, 'TOOL', context) + tools = ('mkgmap', 'splitter') + if index not in range(len(tools)): + raise QgsProcessingException('tool_invalid') + tool = tools[index] + explicit = self.parameterAsFile(parameters, 'JAR_PATH', context) + auto_download = self.parameterAsBool( + parameters, 'AUTO_DOWNLOAD', context) + path = discover_tool(tool, explicit) + status = 'valid' if path else 'missing' + if not path and auto_download: + try: + path = ensure_tool( + tool, explicit, True, feedback, start=0, span=100) + except ValueError as exc: + raise QgsProcessingException(str(exc)) + status = 'downloaded' + manifest = tool_manifest(tool) + details = json.dumps({ + 'tool': tool, + 'main_jar_name': manifest['main_jar_name'], + 'required_dirs': list(manifest['required_dirs']), + 'archive_url': manifest['archive_url'], + }, ensure_ascii=False, sort_keys=True) + feedback.pushInfo('{0}: {1}'.format(tool, status)) + feedback.setProgress(100) + return {'STATUS': status, 'PATH': path or '', 'DETAILS': details} + + def createInstance(self): + return DependencyDiagnosticsDownloadAlgorithm() diff --git a/processing_provider/provider.py b/processing_provider/provider.py new file mode 100644 index 0000000..b0bacc8 --- /dev/null +++ b/processing_provider/provider.py @@ -0,0 +1,45 @@ +# -*- coding: utf-8 -*- +"""Stable Garmin Export Processing provider registration.""" + +from qgis.core import QgsProcessingProvider + +from .algorithms import ( + BuildTypMappingAlgorithm, + ExportSelectedLayersAlgorithm, + ValidateEnvironmentAlgorithm, +) +from .extra_algorithms import ( + DependencyDiagnosticsDownloadAlgorithm, + GenerateMpPreviewAlgorithm, + ValidateMappingJsonAlgorithm, + ValidateTypCodePageAlgorithm, +) + + +class GarminProcessingProvider(QgsProcessingProvider): + """Processing registry entry; no GUI or iface dependency.""" + + def id(self): + return 'garmin_export' + + def name(self): + return 'Garmin Export' + + def longName(self): + return self.name() + + def icon(self): + from qgis.PyQt.QtGui import QIcon + import os + path = os.path.join(os.path.dirname(os.path.dirname(__file__)), + 'icon.png') + return QIcon(path) + + def loadAlgorithms(self): + self.addAlgorithm(ValidateEnvironmentAlgorithm()) + self.addAlgorithm(BuildTypMappingAlgorithm()) + self.addAlgorithm(ExportSelectedLayersAlgorithm()) + self.addAlgorithm(ValidateTypCodePageAlgorithm()) + self.addAlgorithm(GenerateMpPreviewAlgorithm()) + self.addAlgorithm(ValidateMappingJsonAlgorithm()) + self.addAlgorithm(DependencyDiagnosticsDownloadAlgorithm()) diff --git a/processing_provider/runtime.py b/processing_provider/runtime.py new file mode 100644 index 0000000..f4debaa --- /dev/null +++ b/processing_provider/runtime.py @@ -0,0 +1,65 @@ +# -*- coding: utf-8 -*- +"""Processing-side discovery and explicit tool installation. + +This module reuses the UI downloader's atomic archive transaction. Processing +never installs silently: callers must opt in with AUTO_DOWNLOAD. +""" + +import os + +from ..core import downloader +from ..core.download_worker import get_tools_directory +from ..core.mkgmap_compiler import validate_tool_installation +from ..translation_manager import translations + + +def discover_tool(tool, explicit_path=''): + """Return a valid installed JAR, preferring an explicit path.""" + if explicit_path and validate_tool_installation(explicit_path, tool): + return os.path.abspath(explicit_path) + root = get_tools_directory() + candidates = [] + for dirpath, _dirnames, filenames in os.walk(root): + jar_name = '{0}.jar'.format(tool) + if jar_name in filenames: + candidates.append(os.path.join(dirpath, jar_name)) + for candidate in sorted(candidates): + if validate_tool_installation(candidate, tool): + return os.path.abspath(candidate) + return '' + + +def ensure_tool(tool, explicit_path, auto_download, feedback, start=0, + span=30): + """Discover or explicitly download a complete tool distribution.""" + found = discover_tool(tool, explicit_path) + if found: + feedback.pushInfo('{0} found: {1}'.format(tool, found)) + return found + if not auto_download: + raise ValueError('{0}_missing: provide a valid path or enable ' + 'AUTO_DOWNLOAD'.format(tool)) + + tools_dir = get_tools_directory() + + def progress(received, total, status): + if total: + value = start + span * min(1.0, float(received) / total) + feedback.setProgress(value) + if status: + feedback.pushInfo('{0}: {1}'.format(tool, status)) + + try: + path = downloader.download_tool( + tool, tools_dir, progress_callback=progress, + cancelled_callback=feedback.isCanceled, + language=translations.get_current_language()) + except downloader.DownloadCancelledError: + raise ValueError('cancelled') + except Exception as exc: + code = getattr(exc, 'code', 'download_failed') + raise ValueError('{0}_download_{1}'.format(tool, code)) + if feedback.isCanceled(): + raise ValueError('cancelled') + feedback.pushInfo('{0} downloaded: {1}'.format(tool, path)) + return path diff --git a/scripts/verify_plugin_archive.py b/scripts/verify_plugin_archive.py new file mode 100644 index 0000000..8462270 --- /dev/null +++ b/scripts/verify_plugin_archive.py @@ -0,0 +1,49 @@ +# -*- coding: utf-8 -*- +"""Validate that a release ZIP contains only the installable QGIS plugin.""" + +import argparse +import os +import zipfile + + +PLUGIN_ROOT = "garmin_export/" +FORBIDDEN_DIRS = { + ".git", ".github", "docs", "docs_mkgmap", "examples", "plan", "tests", + "scripts", "dist", "wheels", "pics_gui_different", "__pycache__", +} +REQUIRED_FILES = { + "garmin_export/__init__.py", + "garmin_export/garmin_exporter.py", + "garmin_export/metadata.txt", + "garmin_export/icon.png", + "garmin_export/LICENSE", + "garmin_export/qgis_compat.py", + "garmin_export/processing_provider/__init__.py", +} + + +def validate(path): + with zipfile.ZipFile(path) as archive: + names = set(archive.namelist()) + if not names: + raise ValueError("archive is empty") + if any(not name.startswith(PLUGIN_ROOT) for name in names): + raise ValueError("archive contains paths outside garmin_export/") + for name in names: + parts = name.rstrip("/").split("/") + if len(parts) > 1 and parts[1] in FORBIDDEN_DIRS: + raise ValueError("forbidden release path: {0}".format(name)) + if name.lower().endswith((".jar", ".pyc", ".pyo")): + raise ValueError("bundled runtime or bytecode file: {0}".format(name)) + missing = REQUIRED_FILES.difference(names) + if missing: + raise ValueError("missing required files: {0}".format(sorted(missing))) + return len(names) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser() + parser.add_argument("archive") + args = parser.parse_args() + count = validate(os.path.abspath(args.archive)) + print("Validated plugin archive: {0} files".format(count)) diff --git a/tests/test_processing_provider_contract.py b/tests/test_processing_provider_contract.py new file mode 100644 index 0000000..6735ccd --- /dev/null +++ b/tests/test_processing_provider_contract.py @@ -0,0 +1,116 @@ +# -*- coding: utf-8 -*- +"""Offline contract guards for the G6 Processing provider boundary.""" + +import ast +import os + + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + + +def _source(relative): + with open(os.path.join(ROOT, relative), encoding='utf-8') as handle: + return handle.read() + + +def test_provider_registers_exactly_seven_stable_algorithms(): + source = _source('processing_provider/provider.py') + assert source.count('self.addAlgorithm(') == 7 + algorithms = (_source('processing_provider/algorithms.py') + + _source('processing_provider/extra_algorithms.py')) + for algorithm_id in ( + 'validate_environment', 'build_typ_mapping', + 'export_selected_layers', 'validate_typ_code_page', + 'generate_mp_preview', 'validate_mapping_json', + 'dependency_diagnostics_download'): + assert "return '{0}'".format(algorithm_id) in algorithms + + +def test_processing_boundary_has_no_gui_side_effect_imports(): + for relative in ('processing_provider/provider.py', + 'processing_provider/algorithms.py', + 'processing_provider/runtime.py', + 'processing_provider/extra_algorithms.py'): + tree = ast.parse(_source(relative)) + imported = [node for node in ast.walk(tree) + if isinstance(node, (ast.Import, ast.ImportFrom))] + imported_names = ' '.join(ast.unparse(node) for node in imported) + assert 'QMessageBox' not in imported_names + assert 'QWidget' not in imported_names + assert 'qgis.PyQt.QtWidgets' not in imported_names + + +def test_processing_algorithms_use_feedback_and_stable_parameter_keys(): + source = _source('processing_provider/algorithms.py') + for token in ('feedback.isCanceled', 'feedback.setProgress', + 'QgsProcessingException', 'LAYERS', 'MKGMP', 'OUTPUT', + 'AUTO_DOWNLOAD', 'ensure_tool', 'TYP_MODE', 'TYP_FILE', + 'ENABLED_LEVELS', 'typ_file_path', 'DRAW_PRIORITY', + 'MKGMAP_LOGGING', 'EXTRA_ARGS', 'MANIFEST'): + assert token in source + + +def test_processing_provider_registration_is_duplicate_safe(): + source = _source('garmin_exporter.py') + assert "providerById('garmin_export')" in source + assert 'registry.addProvider(provider)' in source + assert 'registry.removeProvider(provider.id())' in source + + +def test_g6_production_files_stay_within_size_rule(): + for relative in ('processing_provider/__init__.py', + 'processing_provider/provider.py', + 'processing_provider/algorithms.py', + 'processing_provider/runtime.py', + 'processing_provider/extra_algorithms.py'): + assert len(_source(relative).splitlines()) <= 500 + + +def test_processing_provider_is_declared_in_metadata(): + assert 'hasProcessingProvider=yes' in _source('metadata.txt') + + +def test_runtime_reuses_the_ui_downloader_contract(): + source = _source('processing_provider/runtime.py') + assert 'discover_tool' in source + assert 'downloader.download_tool' in source + assert 'validate_tool_installation' in source + assert 'cancelled_callback=feedback.isCanceled' in source + assert 'translations.get_current_language()' in source + + +def test_processing_export_exposes_styling_and_level_controls(): + source = _source('processing_provider/algorithms.py') + for token in ('QgsProcessingParameterEnum', + 'Generate from layer styles', + 'Use existing TYP/TXT file', + 'Garmin default (no TYP)', + 'levels_invalid'): + assert token in source + + +def test_author_dialog_refreshes_metadata_from_metadata_file(): + dialog = _source('gui/gui_dialogs.py') + exporter = _source('garmin_exporter.py') + for token in ('metadata_label', 'get_plugin_info()', 'metadata_details', + 'QFileSystemWatcher', '_metadata_changed', 'changelog'): + assert token in dialog + for token in ('qgis_minimum', 'qgis_maximum', 'has_processing_provider', + 'repository', 'tags', 'changelog'): + assert token in exporter + + +def test_processing_optional_flags_support_qgis3_and_qgis4(): + source = _source('processing_provider/algorithms.py') + assert 'ProcessingParameterFlag' in source + assert 'FlagOptional' in source + assert 'setOptional' not in source + + +def test_processing_follow_up_algorithms_have_stable_contracts(): + source = _source('processing_provider/extra_algorithms.py') + for token in ('validate_typ_code_page', 'generate_mp_preview', + 'CODE_PAGE_STATUS', 'TYP_STATUS', 'MPGenerator', + 'validate_mapping_json', 'dependency_diagnostics_download', + 'AUTO_DOWNLOAD', 'tool_manifest', 'feedback.isCanceled'): + assert token in source diff --git a/tests/test_qgis_compat.py b/tests/test_qgis_compat.py index 2f857a3..244cd96 100644 --- a/tests/test_qgis_compat.py +++ b/tests/test_qgis_compat.py @@ -65,6 +65,12 @@ def test_qt6_sensitive_spellings_are_shimmed(self): re.search(pattern, source), '{0}: forbidden {1}'.format(path, pattern)) + def test_ci_reports_checker_and_rejects_rewrites(self): + workflow = _source('.github/workflows/ci.yml') + self.assertIn('pyqt5_to_pyqt6.py --dry_run', workflow) + self.assertIn('pyqt5_to_pyqt6.py .', workflow) + self.assertIn('git diff --exit-code', workflow) + def test_compat_imports_are_package_relative(self): for path in _python_files(): if path == 'qgis_compat.py': diff --git a/translations/ar.py b/translations/ar.py index 32d3f38..a33de37 100644 --- a/translations/ar.py +++ b/translations/ar.py @@ -138,6 +138,11 @@ 'contact': 'التواصل', 'year': 'السنة', 'organization': 'المؤسّسة', + 'tags': 'الوسوم', + 'metadata_details': 'البيانات الوصفية', + 'qgis_support': 'دعم QGIS', + 'processing_provider': 'مزود Processing', + 'changelog': 'سجل التغييرات', 'plugin_description': 'أداة احترافية لتصدير بيانات QGIS ' 'إلى خرائط Garmin IMG عبر mkgmap', 'multilingual_support': 'التنسيق من QGIS، وإنشاء TYP، والضبط الدقيق لـ mkgmap، ' diff --git a/translations/de.py b/translations/de.py index f24f628..dc5b4e8 100644 --- a/translations/de.py +++ b/translations/de.py @@ -138,6 +138,11 @@ 'contact': 'Kontakt', 'year': 'Jahr', 'organization': 'Organisation', + 'tags': 'Tags', + 'metadata_details': 'Metadaten', + 'qgis_support': 'QGIS-Unterstützung', + 'processing_provider': 'Processing-Anbieter', + 'changelog': 'Änderungsprotokoll', 'plugin_description': 'Professionelles Werkzeug zum Exportieren von QGIS-Daten ' 'in Garmin-IMG-Karten über mkgmap', 'multilingual_support': 'QGIS-Gestaltung, TYP-Erzeugung, mkgmap-Feineinstellung, ' diff --git a/translations/en.py b/translations/en.py index 002f1c5..fe61ac5 100644 --- a/translations/en.py +++ b/translations/en.py @@ -138,6 +138,11 @@ 'contact': 'Contact', 'year': 'Year', 'organization': 'Organization', + 'tags': 'Tags', + 'metadata_details': 'Metadata', + 'qgis_support': 'QGIS support', + 'processing_provider': 'Processing provider', + 'changelog': 'Changelog', 'plugin_description': 'Professional tool for exporting QGIS data ' 'to Garmin IMG maps via mkgmap', 'multilingual_support': 'QGIS styling, TYP generation, mkgmap fine-tuning, ' diff --git a/translations/es.py b/translations/es.py index d23e9df..4d268d0 100644 --- a/translations/es.py +++ b/translations/es.py @@ -138,6 +138,11 @@ 'contact': 'Contacto', 'year': 'Año', 'organization': 'Organización', + 'tags': 'Etiquetas', + 'metadata_details': 'Metadatos', + 'qgis_support': 'Compatibilidad con QGIS', + 'processing_provider': 'Proveedor de Processing', + 'changelog': 'Registro de cambios', 'plugin_description': 'Herramienta profesional para exportar datos de QGIS ' 'a mapas Garmin IMG mediante mkgmap', 'multilingual_support': 'Estilo desde QGIS, generación de TYP, ajuste fino de mkgmap, ' diff --git a/translations/fr.py b/translations/fr.py index b902b1a..37fb296 100644 --- a/translations/fr.py +++ b/translations/fr.py @@ -138,6 +138,11 @@ 'contact': 'Contact', 'year': 'Année', 'organization': 'Organisation', + 'tags': 'Balises', + 'metadata_details': 'Métadonnées', + 'qgis_support': 'Compatibilité QGIS', + 'processing_provider': 'Fournisseur Processing', + 'changelog': 'Journal des modifications', 'plugin_description': 'Outil professionnel pour exporter les données QGIS ' 'vers des cartes Garmin IMG via mkgmap', 'multilingual_support': 'Style depuis QGIS, génération de TYP, réglage fin de mkgmap, ' diff --git a/translations/hi.py b/translations/hi.py index 64adcf2..3ee32fb 100644 --- a/translations/hi.py +++ b/translations/hi.py @@ -138,6 +138,11 @@ 'contact': 'संपर्क', 'year': 'वर्ष', 'organization': 'संगठन', + 'tags': 'टैग', + 'metadata_details': 'मेटाडेटा', + 'qgis_support': 'QGIS समर्थन', + 'processing_provider': 'Processing प्रदाता', + 'changelog': 'परिवर्तन लॉग', 'plugin_description': 'mkgmap के माध्यम से QGIS डेटा को ' 'Garmin IMG मानचित्रों में निर्यात करने का पेशेवर उपकरण', 'multilingual_support': 'QGIS शैली, TYP निर्माण, mkgmap फ़ाइन-ट्यूनिंग, ' diff --git a/translations/id.py b/translations/id.py index 7cd7ead..94ac85f 100644 --- a/translations/id.py +++ b/translations/id.py @@ -138,6 +138,11 @@ 'contact': 'Kontak', 'year': 'Tahun', 'organization': 'Organisasi', + 'tags': 'Tag', + 'metadata_details': 'Metadata', + 'qgis_support': 'Dukungan QGIS', + 'processing_provider': 'Penyedia Processing', + 'changelog': 'Catatan perubahan', 'plugin_description': 'Alat profesional untuk mengekspor data QGIS ' 'ke peta Garmin IMG melalui mkgmap', 'multilingual_support': 'Penggayaan QGIS, pembuatan TYP, penyetelan mkgmap, ' diff --git a/translations/pt.py b/translations/pt.py index cbaca73..307d386 100644 --- a/translations/pt.py +++ b/translations/pt.py @@ -138,6 +138,11 @@ 'contact': 'Contato', 'year': 'Ano', 'organization': 'Organização', + 'tags': 'Tags', + 'metadata_details': 'Metadados', + 'qgis_support': 'Compatibilidade QGIS', + 'processing_provider': 'Provedor Processing', + 'changelog': 'Histórico de alterações', 'plugin_description': 'Ferramenta profissional para exportar dados do QGIS ' 'para mapas Garmin IMG via mkgmap', 'multilingual_support': 'Estilo do QGIS, geração de TYP, ajuste fino do mkgmap, ' diff --git a/translations/ru.py b/translations/ru.py index d09fc21..fca3d3f 100644 --- a/translations/ru.py +++ b/translations/ru.py @@ -138,6 +138,11 @@ 'contact': 'Контакт', 'year': 'Год', 'organization': 'Организация', + 'tags': 'Теги', + 'metadata_details': 'Метаданные', + 'qgis_support': 'Поддержка QGIS', + 'processing_provider': 'Провайдер Processing', + 'changelog': 'История изменений', 'plugin_description': 'Профессиональный инструмент экспорта данных QGIS ' 'в формат карт Garmin IMG через mkgmap', 'multilingual_support': 'Стилизация из QGIS, генерация TYP, тонкая ' diff --git a/translations/th.py b/translations/th.py index dbebe3c..07e9651 100644 --- a/translations/th.py +++ b/translations/th.py @@ -138,6 +138,11 @@ 'contact': 'ติดต่อ', 'year': 'ปี', 'organization': 'องค์กร', + 'tags': 'แท็ก', + 'metadata_details': 'ข้อมูลเมตา', + 'qgis_support': 'รองรับ QGIS', + 'processing_provider': 'ผู้ให้บริการ Processing', + 'changelog': 'บันทึกการเปลี่ยนแปลง', 'plugin_description': 'เครื่องมือระดับมืออาชีพสำหรับส่งออกข้อมูล QGIS ' 'ไปยังแผนที่ Garmin IMG ผ่าน mkgmap', 'multilingual_support': 'จัดสไตล์จาก QGIS, สร้าง TYP, ปรับแต่ง mkgmap, ' diff --git a/translations/vi.py b/translations/vi.py index a5b8f4c..c2a57e3 100644 --- a/translations/vi.py +++ b/translations/vi.py @@ -138,6 +138,11 @@ 'contact': 'Liên hệ', 'year': 'Năm', 'organization': 'Tổ chức', + 'tags': 'Thẻ', + 'metadata_details': 'Siêu dữ liệu', + 'qgis_support': 'Hỗ trợ QGIS', + 'processing_provider': 'Nhà cung cấp Processing', + 'changelog': 'Nhật ký thay đổi', 'plugin_description': 'Công cụ chuyên nghiệp để xuất dữ liệu QGIS ' 'sang bản đồ Garmin IMG qua mkgmap', 'multilingual_support': 'Tạo kiểu từ QGIS, tạo TYP, tinh chỉnh mkgmap, ' diff --git a/translations/zh.py b/translations/zh.py index 6646625..21a0f9f 100644 --- a/translations/zh.py +++ b/translations/zh.py @@ -138,6 +138,11 @@ 'contact': '联系方式', 'year': '年份', 'organization': '组织', + 'tags': '标签', + 'metadata_details': '元数据', + 'qgis_support': 'QGIS 支持', + 'processing_provider': 'Processing 提供程序', + 'changelog': '更新日志', 'plugin_description': '通过 mkgmap 将 QGIS 数据导出为 ' 'Garmin IMG 地图的专业工具', 'multilingual_support': 'QGIS 样式、TYP 生成、mkgmap 精细调优、'