diff --git a/.gitignore b/.gitignore index 9ca63b1..27c6d88 100644 --- a/.gitignore +++ b/.gitignore @@ -224,3 +224,6 @@ resources.py *.db .vscode + +# OpenCode +AGENTS.md diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 147d7fe..fcbd726 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,11 +1,18 @@ repos: + - repo: https://github.com/google/addlicense + rev: v1.2.0 + hooks: + - id: addlicense + args: [ "-f", "assets/license-header.txt"] + types: [python] + - repo: https://github.com/pre-commit/pre-commit-hooks rev: v6.0.0 hooks: - id: check-toml - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.16.1 + rev: v0.16.4 hooks: - id: ruff args: [--fix] diff --git a/assets/license-header.txt b/assets/license-header.txt new file mode 100644 index 0000000..0e2da83 --- /dev/null +++ b/assets/license-header.txt @@ -0,0 +1,15 @@ +NextGIS QuickMapServices +Copyright (C) {{.Year}} NextGIS + +This program is free software; you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation; either version 2 of the License, or any +later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License along +with this program; if not, see . \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 47c151d..8787d63 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,8 +5,7 @@ readme = "README.md" license = { file = "LICENSE" } -[tool.qgspb.package-data] -"quick_map_services" = [".bandit"] +[tool.qgsmith.package-data] "quick_map_services.assets" = ["**/*.json"] "quick_map_services.quickmapservices_contrib.data_sources" = [ "**/*.png", @@ -20,7 +19,7 @@ license = { file = "LICENSE" } ] "quick_map_services.icons" = ["*.svg", "*.png", "material/*.svg"] -[tool.qgspb.forms] +[tool.qgsmith.forms] ui-files = [ "src/quick_map_services/gui/*.ui", "src/quick_map_services/scales.xml", @@ -28,7 +27,7 @@ ui-files = [ ] compile = false -[tool.qgspb.translations] +[tool.qgsmith.translations] ts-files = ["src/quick_map_services/i18n/*.ts"] no-obsolete = true @@ -112,3 +111,7 @@ extend-ignore-names = [ "createWidget", "*Event", ] + +[tool.bandit] +exclude_dirs = ["tests", "setup.py"] +skips = ["B101", "B608"] diff --git a/setup.py b/setup.py index ce8a78f..355aa85 100755 --- a/setup.py +++ b/setup.py @@ -1,4 +1,18 @@ -#!/usr/bin/env python3 +# NextGIS QuickMapServices +# Copyright (C) 2026 NextGIS +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or any +# later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, see . import argparse import json @@ -6,13 +20,13 @@ import platform import re import shutil -import subprocess # nosec B404 +import subprocess import sys import tempfile import zipfile from configparser import ConfigParser from pathlib import Path -from typing import Dict, List, Optional, Sequence +from typing import Dict, List, Optional try: import tomllib @@ -26,6 +40,19 @@ def with_name( return path.with_name(f"{prefix}{path.stem}{suffix}{extension}") +def replace_metadata_version(content: str, version: str) -> str: + updated_content, replacements = re.subn( + r"(?m)^(version\s*=\s*).*$", + rf"\g<1>{version}", + content, + count=1, + ) + if replacements != 1: + raise RuntimeError("Could not find metadata version line") + + return updated_content + + class QgisPluginBuilder: def __init__(self): self.current_directory = Path(__file__).parent @@ -33,37 +60,14 @@ def __init__(self): self.settings = tomllib.loads(pyproject_file.read_text()) self.project_settings = self.settings.get("project", {}) - self.qgspb_settings = self.settings.get("tool", {}).get("qgspb", {}) - self.data_settings = self.qgspb_settings.get("package-data", {}) - self.ui_settings = self.qgspb_settings.get("forms", {}) - self.qrc_settings = self.qgspb_settings.get("resources", {}) - self.ts_settings = self.qgspb_settings.get("translations", {}) - - def __resolve_executable(self, executable_name: str) -> str: - executable_path = shutil.which(executable_name) - if executable_path is None: - raise RuntimeError( - f'Executable "{executable_name}" was not found in PATH' - ) - return executable_path - - def __run_command( - self, - executable_name: str, - arguments: Sequence[str], - *, - cwd: Optional[Path] = None, - capture_output: bool = False, - text: bool = False, - ) -> subprocess.CompletedProcess: - executable_path = self.__resolve_executable(executable_name) - return subprocess.run( # nosec B603 - [executable_path, *arguments], - capture_output=capture_output, - check=True, - cwd=str(cwd) if cwd is not None else None, - text=text, + self.qgsmith_settings = self.settings.get("tool", {}).get( + "qgsmith", {} ) + self.bandit_settings = self.settings.get("tool", {}).get("bandit", {}) + self.data_settings = self.qgsmith_settings.get("package-data", {}) + self.ui_settings = self.qgsmith_settings.get("forms", {}) + self.qrc_settings = self.qgsmith_settings.get("resources", {}) + self.ts_settings = self.qgsmith_settings.get("translations", {}) def bootstrap( self, @@ -71,105 +75,24 @@ def bootstrap( compile_ui: Optional[bool] = None, compile_qrc: Optional[bool] = None, compile_ts: Optional[bool] = None, - update_git_submodules: Optional[bool] = None, + fix_ui: Optional[bool] = None, ) -> None: - """ - Prepare the plugin environment and compile necessary components. - - :param compile_ui: Compile UI forms if True. - :type compile_ui: Optional[bool] - :param compile_qrc: Compile resource files if True. - :type compile_qrc: Optional[bool] - :param compile_ts: Compile translation files if True. - :type compile_ts: Optional[bool] - :param update_git_submodules: Update missing git submodules if True. - :type update_git_submodules: Optional[bool] - """ - - # Enable full setup mode if no explicit flags are provided if all( setting is None - for setting in ( - compile_ui, - compile_qrc, - compile_ts, - update_git_submodules, - ) + for setting in (compile_ui, compile_qrc, compile_ts) ): - update_git_submodules = True compile_ui = True compile_qrc = True compile_ts = True - if update_git_submodules: - self.update_git_submodules() if compile_ui: self.compile_ui() if compile_qrc: self.compile_qrc() if compile_ts: self.compile_ts() - - def update_git_submodules(self) -> None: - """ - Update missing git submodules by reading .gitmodules and - initializing submodules that are not present. - - :raises RuntimeError: If git is not available or update fails. - """ - # Construct path to .gitmodules file - gitmodules_path = self.current_directory / ".gitmodules" - if not gitmodules_path.exists(): - return - - # Parse .gitmodules to get all submodule paths - submodule_paths = self._parse_gitmodules(gitmodules_path) - # Identify missing submodules - missing_submodules = [ - path - for path in submodule_paths - if not (self.current_directory / path / ".git").exists() - ] - if not missing_submodules: - return - - for submodule in missing_submodules: - print(f":: Initializing missing submodule: {submodule}") - try: - self.__run_command( - "git", - [ - "submodule", - "update", - "--init", - "--", - str(submodule), - ], - cwd=self.current_directory, - ) - except Exception as exc: - raise RuntimeError( - f"Failed to update submodule {submodule}: {exc}" - ) - - def _parse_gitmodules(self, gitmodules_path: Path) -> List[str]: - """ - Parse .gitmodules file and return a list of submodule paths. - - :param gitmodules_path: Path to .gitmodules file. - :type gitmodules_path: Path - :return: List of submodule paths. - :rtype: List[str] - """ - paths = [] - pattern = re.compile(r"^\s*path\s*=\s*(.+)$") - - # Extract 'path =' entries from .gitmodules - for line in gitmodules_path.read_text(encoding="utf-8").splitlines(): - match = pattern.match(line) - if match: - paths.append(match.group(1).strip()) - return paths + if fix_ui: + self.fix_ui() def compile_ui(self) -> None: if len(self.ui_settings) == 0 or not self.ui_settings.get( @@ -187,9 +110,8 @@ def compile_ui(self) -> None: ] for ui_path in ui_paths: output_path = with_name(ui_path, prefix, suffix, ".py") - self.__run_command( - "pyuic5", - ["-o", str(output_path), str(ui_path)], + subprocess.check_output( + ["pyuic5", "-o", str(output_path), str(ui_path)] ) self.__update_generated_file(output_path) @@ -207,9 +129,8 @@ def compile_qrc(self) -> None: ] for qrc_path in qrc_paths: output_path = with_name(qrc_path, prefix, suffix, ".py") - self.__run_command( - "pyrcc5", - ["-o", str(output_path), str(qrc_path)], + subprocess.check_output( + ["pyrcc5", "-o", str(output_path), str(qrc_path)] ) self.__update_generated_file(output_path) @@ -225,7 +146,27 @@ def compile_ts(self): for ts_path in Path(__file__).parent.rglob(ts_pattern) ) - self.__run_command(command_args[0], command_args[1:]) + subprocess.check_output(command_args) + + def fix_ui(self) -> None: + ENUMS = { + "Qt::AlignmentFlag", + "Qt::Orientation", + "Qt::TextFormat", + "QSizePolicy::Policy", + "QDialogButtonBox::StandardButton", + } + ui_patterns = self.ui_settings.get("ui-files", []) + ui_paths = [ + ui_path + for ui_pattern in ui_patterns + for ui_path in Path(__file__).parent.rglob(ui_pattern) + ] + for ui_path in ui_paths: + content = ui_path.read_text(encoding="utf-8") + for enum in ENUMS: + content = content.replace(enum, enum[: enum.find(("::"))]) + ui_path.write_text(content, encoding="utf-8") def build(self) -> None: self.bootstrap() @@ -259,6 +200,15 @@ def create_directories(zip_file: zipfile.ZipFile, path: Path): create_directories(zip_file, build_path) zip_file.write(source_file, "/".join(build_path.parts)) + bandit_config = self.__create_bandit_config() + if bandit_config is not None: + bandit_path = Path(project_name) / ".bandit" + create_directories(zip_file, bandit_path) + zip_file.writestr( + "/".join(bandit_path.parts), + bandit_config, + ) + def install( self, qgis: str, @@ -352,10 +302,7 @@ def uninstall(self, qgis: str, profile: Optional[str]) -> None: return metadata_path = plugin_path / "metadata.txt" - if not metadata_path.exists(): - raise FileNotFoundError( - f"Missing metadata file for installed plugin: {metadata_path}" - ) + assert metadata_path.exists() metadata = ConfigParser() with open(metadata_path, encoding="utf-8") as f: @@ -440,7 +387,7 @@ def update_ts(self): for ts_path in Path(__file__).parent.rglob(ts_pattern) ) - self.__run_command(command_args[0], command_args[1:]) + subprocess.check_output(command_args) # TODO (ivanbarsukov): check unfinished in ts files @@ -469,15 +416,38 @@ def __create_metadata_mapping(self) -> Dict[Path, Path]: metadata.read_file(f) metadata_version = metadata.get("general", "version") if metadata_version != project_version: - raise RuntimeError( - "Project version does not match plugin metadata version: " - f"{project_version} != {metadata_version}" + self.__confirm_metadata_version_overwrite( + metadata_path, + metadata_version, + project_version, ) build_path = Path(project_name) / metadata_path.name return {metadata_path: build_path} + def __confirm_metadata_version_overwrite( + self, + metadata_path: Path, + metadata_version: str, + project_version: str, + ) -> None: + print( + "metadata.txt version differs from pyproject.toml: " + f"{metadata_version} != {project_version}" + ) + confirmation = ( + input(":: Overwrite metadata.txt version? [y/N] ").strip().lower() + ) + if confirmation != "y": + raise RuntimeError("metadata.txt version update was cancelled") + + metadata_content = metadata_path.read_text(encoding="utf-8") + metadata_path.write_text( + replace_metadata_version(metadata_content, project_version), + encoding="utf-8", + ) + def __create_readme_mapping(self) -> Dict[Path, Path]: if "readme" not in self.project_settings: return {} @@ -504,8 +474,7 @@ def __create_license_mapping(self) -> Dict[Path, Path]: license_setting = self.project_settings["license"] license_file = license_setting["file"] - if not isinstance(license_file, str): - raise RuntimeError("License file setting must be a string") + assert isinstance(license_file, str) project_name: str = self.project_settings["name"] @@ -514,11 +483,35 @@ def __create_license_mapping(self) -> Dict[Path, Path]: return {file_path: build_path} + def __create_bandit_config(self) -> Optional[str]: + if len(self.bandit_settings) == 0: + return None + + bandit_options = { + "exclude_dirs": "exclude", + "tests": "tests", + "skips": "skips", + } + config_lines = ["[bandit]"] + for source_option, target_option in bandit_options.items(): + option_value = self.bandit_settings.get(source_option) + if option_value is None: + continue + + if isinstance(option_value, list): + serialized_value = ",".join(option_value) + else: + serialized_value = str(option_value) + + config_lines.append(f"{target_option} = {serialized_value}") + + return "\n".join(config_lines) + "\n" + def __create_sources_mapping(self) -> Dict[Path, Path]: project_name: str = self.project_settings["name"] src_directory = Path(__file__).parent / "src" - exclude_patterns = self.qgspb_settings.get("exclude-files", []) + exclude_patterns = self.qgsmith_settings.get("exclude-files", []) exclude_paths = set( exclude_path.absolute() for exclude_pattern in exclude_patterns @@ -620,10 +613,7 @@ def __create_translations_mapping(self) -> Dict[Path, Path]: return result def __update_generated_file(self, file_path: Path) -> None: - if file_path.suffix != ".py": - raise ValueError( - f"Generated file must have .py suffix: {file_path}" - ) + assert file_path.suffix == ".py" content = file_path.read_text(encoding="utf-8") file_path.write_text(content.replace("from PyQt5", "from qgis.PyQt")) @@ -631,8 +621,10 @@ def __profile_path(self, qgis: str, profile: Optional[str]) -> Path: system = platform.system() if qgis in ("Vanilla", "VanillaFlatpak"): - qgis_profiles = Path("QGIS/QGIS3/profiles") - elif qgis in ("NextGIS", "NextGISFlatpak"): + qgis_profiles = Path("QGIS/QGIS4/profiles") + elif qgis == "NextGIS": + qgis_profiles = Path("NextGIS/NGQ3/profiles") + elif qgis == "NextGISFlatpak": qgis_profiles = Path("NextGIS/ngqgis/profiles") else: raise RuntimeError(f"Unknown QGIS: {qgis}") @@ -655,8 +647,7 @@ def __profile_path(self, qgis: str, profile: Optional[str]) -> Path: elif system == "Windows": appdata = os.getenv("APPDATA") - if appdata is None: - raise RuntimeError("APPDATA environment variable is not set") + assert appdata is not None profiles_path = Path(appdata) / qgis_profiles elif system == "Darwin": # macOS @@ -947,22 +938,21 @@ def __create_dotenv_for_windows(self) -> None: return # Capture current state - result = self.__run_command( - "cmd.exe", - ["/d", "/s", "/c", "set"], + result = subprocess.run( + ["cmd.exe", "/c", "set"], capture_output=True, text=True, + shell=True, + check=True, ) current_env = self.__extract_env_vars_from_set_output(result.stdout) - bat_command = ( - f"{subprocess.list2cmdline(['call', str(bat_file)])} && set" - ) - result = self.__run_command( - "cmd.exe", - ["/d", "/s", "/c", bat_command], + result = subprocess.run( + f'cmd.exe /c "{bat_file} & set"', capture_output=True, text=True, + shell=True, + check=True, ) updated_env = self.__extract_env_vars_from_set_output(result.stdout) @@ -1002,22 +992,18 @@ def __create_env_bat_file(self) -> Optional[Path]: if source_bat_file is None: return None - file_descriptor, temp_bat_path = tempfile.mkstemp( - prefix="qgis_env", suffix=".bat" - ) - os.close(file_descriptor) - temp_bat_file = Path(temp_bat_path) + temp_bat_file = tempfile.mktemp(prefix="qgis_env", suffix=".bat") source_dir = str(source_bat_file.parent) source_bat = source_bat_file.read_text() - with temp_bat_file.open("w") as file: + with open(temp_bat_file, "w") as file: for line in source_bat.split("\n"): if line.lstrip().startswith(("start", "python", "@echo on")): continue line = line.replace("%~dp0", source_dir) file.write(f"{line}\n") - return temp_bat_file + return Path(temp_bat_file) def __create_dotenv_dict( self, current_env: Dict[str, str], updated_env: Dict[str, str] @@ -1108,11 +1094,11 @@ def create_parser(): help="Compile only resources", ) parser_bootstrap.add_argument( - "--git", - dest="update_git_submodules", + "--fix-ui", + dest="fix_ui", + default=None, action="store_true", - default=False, - help="Update missing git submodules", + help="Fix UI files", ) # build command @@ -1193,7 +1179,7 @@ def main() -> None: compile_ui=args.compile_ui, compile_qrc=args.compile_qrc, compile_ts=args.compile_ts, - update_git_submodules=args.update_git_submodules, + fix_ui=args.fix_ui, ) elif args.command == "build": builder.build() diff --git a/src/quick_map_services/.bandit b/src/quick_map_services/.bandit deleted file mode 100644 index d286ded..0000000 --- a/src/quick_map_services/.bandit +++ /dev/null @@ -1,2 +0,0 @@ -[bandit] -skips = B101 \ No newline at end of file diff --git a/src/quick_map_services/about_dialog.py b/src/quick_map_services/about_dialog.py index de5eb0e..4118985 100644 --- a/src/quick_map_services/about_dialog.py +++ b/src/quick_map_services/about_dialog.py @@ -26,13 +26,23 @@ from qgis.core import QgsSettings from qgis.PyQt.QtCore import ( QByteArray, + QEvent, QLocale, + QObject, QRectF, QSize, Qt, + QTimer, QUrl, + pyqtSignal, +) +from qgis.PyQt.QtGui import ( + QDesktopServices, + QHideEvent, + QIcon, + QPainter, + QPixmap, ) -from qgis.PyQt.QtGui import QDesktopServices, QIcon, QPainter, QPixmap from qgis.PyQt.QtSvg import QSvgRenderer from qgis.PyQt.QtWidgets import ( QAbstractItemView, @@ -360,6 +370,10 @@ class AboutDialog(QDialog): _COMPONENT_BUTTON_ICON_SIZE = 16 _COMPONENT_BUTTON_SIZE = 22 _TAB_ICON_SIZE = 16 + _DEVELOPER_MODE_CLICKS = 7 + _DEVELOPER_MODE_CLICK_INTERVAL_MS = 1500 + + developer_mode_toggle_requested = pyqtSignal() def __init__( self, @@ -378,6 +392,10 @@ def __init__( self._components_path = ( Path(components_path) if components_path is not None else None ) + self._version_click_count = 0 + self._version_click_timer = QTimer(self) + self._version_click_timer.setSingleShot(True) + self._version_click_timer.timeout.connect(self._reset_version_clicks) module_spec = importlib.util.find_spec(self._package_name) if module_spec and module_spec.origin: @@ -425,6 +443,8 @@ def _setup_ui(self) -> None: self._version_label = QLabel(self._text("Version {version}"), self) self._version_label.setObjectName("version_label") + self._version_label.setCursor(Qt.CursorShape.PointingHandCursor) + self._version_label.installEventFilter(self) self._info_layout.addWidget(self._version_label) self._header_layout.addLayout(self._info_layout) @@ -525,6 +545,33 @@ def _setup_ui(self) -> None: self._footer_layout.addWidget(self._button_box) grid_layout.addLayout(self._footer_layout, 3, 0) + def eventFilter(self, watched: QObject, event: QEvent) -> bool: # pyright: ignore[reportIncompatibleMethodOverride] + """Request a developer-mode toggle after repeated version clicks.""" + if ( + watched is self._version_label + and event.type() == QEvent.Type.MouseButtonRelease + and event.button() == Qt.MouseButton.LeftButton + ): + self._version_click_count += 1 + if self._version_click_count == self._DEVELOPER_MODE_CLICKS: + self._reset_version_clicks() + self.developer_mode_toggle_requested.emit() + else: + self._version_click_timer.start( + self._DEVELOPER_MODE_CLICK_INTERVAL_MS + ) + + return super().eventFilter(watched, event) + + def hideEvent(self, event: QHideEvent) -> None: # pyright: ignore[reportIncompatibleMethodOverride] + """Discard incomplete click sequences when the dialog is hidden.""" + self._reset_version_clicks() + super().hideEvent(event) + + def _reset_version_clicks(self) -> None: + self._version_click_timer.stop() + self._version_click_count = 0 + def _setup_tab_icons(self) -> None: icon_color = self.palette().text().color().name() description_icon = _render_svg_icon( diff --git a/src/quick_map_services/core/constants.py b/src/quick_map_services/core/constants.py index 55fd0a8..f1e5dc7 100644 --- a/src/quick_map_services/core/constants.py +++ b/src/quick_map_services/core/constants.py @@ -1,4 +1,20 @@ +# NextGIS QuickMapServices +# Copyright (C) 2026 NextGIS +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or any +# later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, see . + PACKAGE_NAME = "quick_map_services" -PLUGIN_NAME = "QuickMapServices" +PLUGIN_NAME = "NextGIS QuickMapServices" COMPANY_NAME = "NextGIS" DEFAULT_TRANSLATION = "en" diff --git a/src/quick_map_services/core/settings.py b/src/quick_map_services/core/settings.py index 22fa7dc..b360e52 100644 --- a/src/quick_map_services/core/settings.py +++ b/src/quick_map_services/core/settings.py @@ -1,3 +1,19 @@ +# NextGIS QuickMapServices +# Copyright (C) 2026 NextGIS +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or any +# later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, see . + import ast import platform from typing import Any, ClassVar, Dict, List, Tuple @@ -28,6 +44,9 @@ class QmsSettings: KEY_DID_LAST_LAUNCH_FAIL = ( f"{COMPANY_NAME}/{PLUGIN_NAME}/other/didLastLaunchFail" ) + KEY_IS_DEVELOPER_MODE_ENABLED = ( + f"{COMPANY_NAME}/{PLUGIN_NAME}/other/developerModeEnabled" + ) KEY_START_SCREEN_SERVICE_LIST_MODE = ( f"{COMPANY_NAME}/{PLUGIN_NAME}/ui/startScreenServiceListMode" ) @@ -61,6 +80,19 @@ def is_debug_logs_enabled(self) -> bool: def is_debug_logs_enabled(self, value: bool) -> None: self.__settings.setValue(self.KEY_IS_DEBUG_LOGS_ENABLED, value) + @property + def is_developer_mode_enabled(self) -> bool: + """Check whether developer mode is enabled.""" + return self.__settings.value( + self.KEY_IS_DEVELOPER_MODE_ENABLED, + defaultValue=False, + type=bool, + ) + + @is_developer_mode_enabled.setter + def is_developer_mode_enabled(self, value: bool) -> None: + self.__settings.setValue(self.KEY_IS_DEVELOPER_MODE_ENABLED, value) + @property def enable_otf_3857(self) -> bool: return self.__settings.value( diff --git a/src/quick_map_services/custom_translator.py b/src/quick_map_services/custom_translator.py deleted file mode 100644 index 6663825..0000000 --- a/src/quick_map_services/custom_translator.py +++ /dev/null @@ -1,48 +0,0 @@ -# -*- coding: utf-8 -*- -""" -/*************************************************************************** - QuickMapServices - A QGIS plugin - Collection of internet map services - ------------------- - begin : 2014-11-21 - git sha : $Format:%H$ - copyright : (C) 2014 by NextGIS - email : info@nextgis.com - ***************************************************************************/ - -/*************************************************************************** - * * - * This program is free software; you can redistribute it and/or modify * - * it under the terms of the GNU General Public License as published by * - * the Free Software Foundation; either version 2 of the License, or * - * (at your option) any later version. * - * * - ***************************************************************************/ -""" - -from qgis.PyQt.QtCore import QCoreApplication, QTranslator - -from .singleton import singleton - - -@singleton -class CustomTranslator: - def __init__(self): - self.__translates = {} - - def append(self, text, translation): - if text and translation: - self.__translates[text] = translation - - def clear_translations(self): - self.__translates.clear() - - def translate(self, context, text): - try: - if ( - isinstance(text, str) or isinstance(text, unicode) - ) and text in self.__translates: - return self.__translates[text] - finally: - return QCoreApplication.translate(context, text) diff --git a/src/quick_map_services/data_source_info.py b/src/quick_map_services/data_source_info.py index 67d4d4e..de9c7f2 100644 --- a/src/quick_map_services/data_source_info.py +++ b/src/quick_map_services/data_source_info.py @@ -84,7 +84,6 @@ def __init__(self): # internal self.file_path = None self.icon_path = None - self.action = None self.category = None def _parse_tms_url(self, url): diff --git a/src/quick_map_services/data_source_serializer.py b/src/quick_map_services/data_source_serializer.py index 1c133c6..c626887 100644 --- a/src/quick_map_services/data_source_serializer.py +++ b/src/quick_map_services/data_source_serializer.py @@ -1,3 +1,19 @@ +# NextGIS QuickMapServices +# Copyright (C) 2026 NextGIS +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or any +# later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, see . + import codecs import configparser import os @@ -6,7 +22,6 @@ from quick_map_services.core import utils from .config_reader_helper import ConfigReaderHelper -from .custom_translator import CustomTranslator from .data_source_info import DataSourceInfo from .fixed_config_parser import FixedConfigParser from .supported_drivers import KNOWN_DRIVERS @@ -55,7 +70,12 @@ def parse_wms_url_parameter(url, parameters_str, ignore_layers=False): class DataSourceSerializer: @classmethod def read_from_ini(cls, ini_file_path): - translator = CustomTranslator() + """Read a data source definition from an INI file. + + :param ini_file_path: Path to the data source INI file. + + :returns: Parsed data source definition. + """ dir_path = os.path.abspath(os.path.join(ini_file_path, os.path.pardir)) @@ -79,9 +99,15 @@ def read_from_ini(cls, ini_file_path): ds.group = ConfigReaderHelper.try_read_config( parser, "ui", "group", reraise=True ) - ds.alias = ConfigReaderHelper.try_read_config( + default_alias = ConfigReaderHelper.try_read_config( parser, "ui", "alias", reraise=True ) + ds.alias = ConfigReaderHelper.try_read_config( + parser, + "ui", + f"alias[{utils.qgis_locale()}]", + default=default_alias, + ) ds.icon = ConfigReaderHelper.try_read_config(parser, "ui", "icon") # Lic & Terms @@ -209,14 +235,6 @@ def read_from_ini(cls, ini_file_path): reraise=(ds.type == KNOWN_DRIVERS.GEOJSON), ) - # try read translations - posible_trans = parser.items("ui") - locale = utils.qgis_locale() - for key, val in posible_trans: - if isinstance(key, str) and key == f"alias[{locale}]": - translator.append(ds.alias, val) - break - # internal stuff ds.file_path = ini_file_path ds.icon_path = os.path.join(dir_path, ds.icon) if ds.icon else None diff --git a/src/quick_map_services/data_sources_catalog.py b/src/quick_map_services/data_sources_catalog.py new file mode 100644 index 0000000..2fb0b1d --- /dev/null +++ b/src/quick_map_services/data_sources_catalog.py @@ -0,0 +1,98 @@ +# NextGIS QuickMapServices +# Copyright (C) 2026 NextGIS +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or any +# later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, see . + +from dataclasses import dataclass +from typing import Dict, Iterable, List + +from quick_map_services.core import utils +from quick_map_services.data_source_info import DataSourceInfo +from quick_map_services.data_sources_list import DataSourcesList +from quick_map_services.group_info import GroupInfo +from quick_map_services.groups_list import GroupsList +from quick_map_services.paths_constants import ALL_DS_PATHS, ALL_GROUP_PATHS + + +@dataclass +class DataSourceGroup: + """A group of data sources ready for presentation. + + :param info: Group metadata. + :param data_sources: Ordered services that belong to the group. + """ + + info: GroupInfo + data_sources: List[DataSourceInfo] + + +class DataSourcesCatalog: + """Load and select local and contributed map services.""" + + def __init__(self) -> None: + """Initialize an empty local services catalog.""" + self._data_sources: Dict[str, DataSourceInfo] = {} + self._groups: Dict[str, GroupInfo] = {} + + def reload(self) -> None: + """Reload group and service definitions from the local catalog.""" + groups_list = GroupsList(ALL_GROUP_PATHS) + data_sources_list = DataSourcesList( + ALL_DS_PATHS, + group_info_map=groups_list.groups, + ) + self._groups = groups_list.groups + self._data_sources = data_sources_list.data_sources + + def grouped_services( + self, + categories: Iterable[str], + hidden_ids: Iterable[str] = (), + ) -> List[DataSourceGroup]: + """Return visible services in sorted groups for the given categories. + + :param categories: Service categories to include. + :param hidden_ids: Identifiers of services excluded from the result. + + :returns: Ordered visible service groups. + """ + category_set = set(categories) + hidden_id_set = set(hidden_ids) + data_sources = [ + data_source + for data_source in self._data_sources.values() + if data_source.category in category_set + ] + groups = utils.collect_groups(data_sources) + visible_groups = utils.filter_hidden_data_sources( + groups, + list(hidden_id_set), + ) + + result = [] + for group_id in utils.sort_group_ids(visible_groups): + group_info = self._groups.get(group_id) + if group_info is None: + group_info = GroupInfo(group_id=group_id, alias=group_id) + + result.append( + DataSourceGroup( + info=group_info, + data_sources=utils.sort_data_sources( + visible_groups[group_id] + ), + ) + ) + + return result diff --git a/src/quick_map_services/data_sources_list.py b/src/quick_map_services/data_sources_list.py index 02bc5c9..5562468 100644 --- a/src/quick_map_services/data_sources_list.py +++ b/src/quick_map_services/data_sources_list.py @@ -23,15 +23,12 @@ import os from pathlib import Path -from typing import List - -from qgis.PyQt.QtGui import QIcon -from qgis.PyQt.QtWidgets import QAction +from typing import Dict, List, Optional from quick_map_services.core.logging import logger -from quick_map_services.custom_translator import CustomTranslator from quick_map_services.data_source_info import DataSourceCategory from quick_map_services.data_source_serializer import DataSourceSerializer +from quick_map_services.group_info import GroupInfo from quick_map_services.groups_list import GroupsList from quick_map_services.paths_constants import ( ALL_DS_PATHS, @@ -47,14 +44,20 @@ class DataSourcesList: data sources, parses them into :class:`DataSource` objects. """ - def __init__(self, ds_paths: List[Path] = ALL_DS_PATHS) -> None: + def __init__( + self, + ds_paths: List[Path] = ALL_DS_PATHS, + group_info_map: Optional[Dict[str, GroupInfo]] = None, + ) -> None: """ Initialize the DataSourcesList and load available data sources. :param ds_paths: List of directories to scan for data sources. + :param group_info_map: Group metadata used to resolve fallback icons. """ self.data_sources = {} self.ds_paths = ds_paths + self.group_info_map = group_info_map self._fill_data_sources_list() def _fill_data_sources_list(self) -> None: @@ -66,7 +69,9 @@ def _fill_data_sources_list(self) -> None: :rtype: None """ self.data_sources = {} - group_info_map = GroupsList().groups + group_info_map = self.group_info_map + if group_info_map is None: + group_info_map = GroupsList().groups for ds_path in self.ds_paths: if ds_path == BASE_DATA_SOURCES_PATH: @@ -95,19 +100,6 @@ def _fill_data_sources_list(self) -> None: group_info = group_info_map.get(ds.group) if group_info is not None: icon_path = group_info.icon - - ds.action = QAction( - QIcon(icon_path), self.tr(ds.alias), None - ) - ds.action.setData(ds) + ds.icon_path = icon_path self.data_sources[ds.id] = ds - - # noinspection PyMethodMayBeStatic - def tr(self, message): - try: - message = str(message) - except: - return message - # noinspection PyTypeChecker,PyArgumentList,PyCallByClass - return CustomTranslator().translate("QuickMapServices", message) diff --git a/src/quick_map_services/ds_edit_dialog.py b/src/quick_map_services/ds_edit_dialog.py index 0bbcec0..aa1d964 100644 --- a/src/quick_map_services/ds_edit_dialog.py +++ b/src/quick_map_services/ds_edit_dialog.py @@ -1,11 +1,28 @@ +# NextGIS QuickMapServices +# Copyright (C) 2026 NextGIS +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or any +# later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, see . + import os import shutil from os import path +from typing import Optional from qgis.PyQt import uic from qgis.PyQt.QtCore import pyqtSlot from qgis.PyQt.QtGui import QIcon, QPixmap -from qgis.PyQt.QtWidgets import QDialog, QFileDialog, QMessageBox +from qgis.PyQt.QtWidgets import QDialog, QFileDialog, QMessageBox, QWidget from quick_map_services.core.settings import QmsSettings from quick_map_services.data_source_info import DataSourceInfo @@ -40,7 +57,7 @@ def is_same(file1, file2): class DsEditDialog(QDialog, FORM_CLASS): - def __init__(self, parent=None): + def __init__(self, parent: Optional[QWidget] = None) -> None: """Constructor.""" super(DsEditDialog, self).__init__(parent) self.setupUi(self) @@ -68,12 +85,6 @@ def __init__(self, parent=None): self.id_validator = LineEditColorValidator( self.txtId, "^[A-Za-z0-9_]+$", error_tooltip=self.tr("Any text") ) - self.alias_validator = LineEditColorValidator( - self.txtAlias, - "^[A-Za-z0-9_ ]+$", - error_tooltip=self.tr("Any text"), - ) - # events self.cmbType.currentIndexChanged.connect(self.change_spec_tab) @@ -104,17 +115,23 @@ def change_spec_tab(self, index=0): drv = self.cmbType.itemData(self.cmbType.currentIndex()) self.tabWidget.addTab(self.DRV_WIDGETS[drv], drv) - def set_ds_info(self, ds_info): - self.ds_info = ds_info + def set_ds_info(self, data_source: DataSourceInfo) -> None: + """Populate the dialog to edit an existing data source. + + :param data_source: Existing data source to edit. + """ + self.ds_info = data_source self.init_with_existing = True - # fill fields self.fill_common_fields() self.fill_specific_fields() - def fill_ds_info(self, ds_info): - self.ds_info = ds_info + def set_ds_info_for_copy(self, data_source: DataSourceInfo) -> None: + """Populate the dialog to create a copy of a data source. + + :param data_source: Data source used as the copy template. + """ + self.ds_info = data_source self.init_with_existing = False - # fill fields self.fill_common_fields() self.fill_specific_fields() @@ -142,7 +159,7 @@ def set_icon(self, icon_path): self.__ds_icon = icon_path self.iconPreview.setPixmap(QPixmap(self.__ds_icon)) - def fill_common_fields(self): + def fill_common_fields(self) -> None: self.txtId.setText(self.ds_info.id) self.txtAlias.setText(self.ds_info.alias) # self.txtIcon.set_path(self.ds_info.icon_path) @@ -168,76 +185,77 @@ def fill_common_fields(self): self.cmbGroup.addItem(self.ds_info.group, non_ex_group) self.cmbGroup.setCurrentIndex(self.cmbGroup.count() - 1) - def fill_specific_fields(self): - # set type - self.cmbType.setCurrentIndex(self.cmbType.findData(self.ds_info.type)) - # fill widgets - for spec_widget in self.DRV_WIDGETS.values(): - spec_widget.fill_form(self.ds_info) - - def accept(self): - new_ds_info = DataSourceInfo() - self.fill_ds_info(new_ds_info) - if not self.validate(new_ds_info): + def fill_specific_fields(self) -> None: + data_source_type = self.ds_info.type + self.cmbType.setCurrentIndex(self.cmbType.findData(data_source_type)) + self.DRV_WIDGETS[data_source_type].fill_form(self.ds_info) + + def accept(self) -> None: + data_source = DataSourceInfo() + self._fill_data_source_from_form(data_source) + if not self.validate(data_source): return if self.init_with_existing: - res = self.save_existing(new_ds_info) + res = self.save_existing(data_source) else: - res = self.create_new(new_ds_info) + res = self.create_new(data_source) if res: super(DsEditDialog, self).accept() - def save_existing(self, ds_info): - if ds_info.id != self.ds_info.id and not self.check_existing_id( - ds_info.id + def save_existing(self, data_source: DataSourceInfo) -> bool: + if data_source.id != self.ds_info.id and not self.check_existing_id( + data_source.id ): return False - if ds_info == self.ds_info: + if data_source == self.ds_info: return True # replace icon if need - if not is_same(ds_info.icon_path, self.ds_info.icon_path): + if not is_same(data_source.icon_path, self.ds_info.icon_path): os.remove(self.ds_info.icon_path) dir_path = os.path.dirname(self.ds_info.file_path) - ico_file_name = path.basename(ds_info.icon_path) - ico_path = path.join(dir_path, ico_file_name) - shutil.copy(ds_info.icon_path, ico_path) + icon_file_name = path.basename(data_source.icon_path) + icon_path = path.join(dir_path, icon_file_name) + shutil.copy(data_source.icon_path, icon_path) # replace gdal_conf if need - if ds_info.type == KNOWN_DRIVERS.GDAL: + if data_source.type == KNOWN_DRIVERS.GDAL: - def copy_new_gdal_file(): + def copy_new_gdal_file() -> None: dir_path = os.path.dirname(self.ds_info.file_path) - gdal_file_name = path.basename(ds_info.gdal_source_file) + gdal_file_name = path.basename(data_source.gdal_source_file) gdal_file_path = path.join(dir_path, gdal_file_name) - shutil.copy(ds_info.gdal_source_file, gdal_file_path) + shutil.copy(data_source.gdal_source_file, gdal_file_path) # old ds = gdal if self.ds_info.type == KNOWN_DRIVERS.GDAL: - if ds_info.gdal_source_file != self.ds_info.gdal_source_file: + if ( + data_source.gdal_source_file + != self.ds_info.gdal_source_file + ): os.remove(self.ds_info.icon_path) copy_new_gdal_file() else: copy_new_gdal_file() # write config - DataSourceSerializer.write_to_ini(ds_info, self.ds_info.file_path) + DataSourceSerializer.write_to_ini(data_source, self.ds_info.file_path) return True - def create_new(self, ds_info): - if not self.check_existing_id(ds_info.id): + def create_new(self, data_source: DataSourceInfo) -> bool: + if not self.check_existing_id(data_source.id): return False # set paths dir_path = path.join( USER_DIR_PATH, DATA_SOURCES_DIR_NAME, - ds_info.id, + data_source.id, ) if path.exists(dir_path): @@ -247,28 +265,28 @@ def create_new(self, ds_info): dir_path += str(salt) ini_path = path.join(dir_path, "metadata.ini") - ico_path = path.join(dir_path, ds_info.icon) + ico_path = path.join(dir_path, data_source.icon) # create dir os.mkdir(dir_path) # copy icon - shutil.copy(ds_info.icon_path, ico_path) + shutil.copy(data_source.icon_path, ico_path) - if ds_info.type == KNOWN_DRIVERS.GDAL: + if data_source.type == KNOWN_DRIVERS.GDAL: # copy gdal file - gdal_file_name = path.basename(ds_info.gdal_source_file) + gdal_file_name = path.basename(data_source.gdal_source_file) gdal_file_path = path.join(dir_path, gdal_file_name) - shutil.copy(ds_info.gdal_source_file, gdal_file_path) + shutil.copy(data_source.gdal_source_file, gdal_file_path) # write config - DataSourceSerializer.write_to_ini(ds_info, ini_path) + DataSourceSerializer.write_to_ini(data_source, ini_path) return True - def check_existing_id(self, ds_id): + def check_existing_id(self, data_source_id: str) -> bool: gl = DataSourcesList() - if ds_id in gl.data_sources.keys(): + if data_source_id in gl.data_sources: QMessageBox.critical( self, self.tr("Error on save group"), @@ -279,34 +297,46 @@ def check_existing_id(self, ds_id): return False return True - def fill_ds_info(self, ds_info): - ds_info.id = self.txtId.text() - ds_info.alias = self.txtAlias.text() + def _fill_data_source_from_form( + self, + data_source: DataSourceInfo, + ) -> None: + """Read dialog fields into a data source. + + :param data_source: Data source receiving form values. + """ + data_source.id = self.txtId.text() + data_source.alias = self.txtAlias.text() # ds_info.icon = os.path.basename(self.txtIcon.get_path()) - ds_info.icon = os.path.basename(self.__ds_icon) + data_source.icon = os.path.basename(self.__ds_icon) - ds_info.lic_name = self.txtLicense.text() - ds_info.lic_link = self.txtLicenseLink.text() - ds_info.copyright_text = self.txtCopyrightText.text() - ds_info.copyright_link = self.txtCopyrightLink.text() - ds_info.terms_of_use = self.txtTermsOfUse.text() + data_source.lic_name = self.txtLicense.text() + data_source.lic_link = self.txtLicenseLink.text() + data_source.copyright_text = self.txtCopyrightText.text() + data_source.copyright_link = self.txtCopyrightLink.text() + data_source.terms_of_use = self.txtTermsOfUse.text() - ds_info.group = self.cmbGroup.itemData(self.cmbGroup.currentIndex()).id - ds_info.type = self.cmbType.itemData(self.cmbType.currentIndex()) + data_source.group = self.cmbGroup.itemData( + self.cmbGroup.currentIndex() + ).id + data_source.type = self.cmbType.itemData(self.cmbType.currentIndex()) - self.DRV_WIDGETS[ds_info.type].fill_ds_info(ds_info) + self.DRV_WIDGETS[data_source.type].fill_ds_info(data_source) - ds_info.icon_path = self.__ds_icon + data_source.icon_path = self.__ds_icon # ds_info.icon_path = self.txtIcon.get_path() - def validate(self, ds_info): + def validate(self, data_source: DataSourceInfo) -> bool: # validate common fields checks = [ - (ds_info.id, self.tr("Please, enter data source id")), - (ds_info.alias, self.tr("Please, enter data source alias")), - (ds_info.icon, self.tr("Please, select icon for data source")), - (ds_info.group, self.tr("Please, select group for data source")), - (ds_info.type, self.tr("Please, select type for data source")), + (data_source.id, self.tr("Please, enter data source id")), + (data_source.alias, self.tr("Please, enter data source alias")), + (data_source.icon, self.tr("Please, select icon for data source")), + ( + data_source.group, + self.tr("Please, select group for data source"), + ), + (data_source.type, self.tr("Please, select type for data source")), ] for val, comment in checks: @@ -323,10 +353,6 @@ def validate(self, ds_info): self.id_validator, self.tr("Please, enter correct value for data source id"), ), - ( - self.alias_validator, - self.tr("Please, enter correct value for data source alias"), - ), ] for val, comment in checks_correct: @@ -339,7 +365,7 @@ def validate(self, ds_info): return False # validate special fields - if not self.DRV_WIDGETS[ds_info.type].validate(ds_info): + if not self.DRV_WIDGETS[data_source.type].validate(data_source): return False return True diff --git a/src/quick_map_services/group_edit_dialog.py b/src/quick_map_services/group_edit_dialog.py index 1fcd034..1622f9a 100644 --- a/src/quick_map_services/group_edit_dialog.py +++ b/src/quick_map_services/group_edit_dialog.py @@ -1,3 +1,19 @@ +# NextGIS QuickMapServices +# Copyright (C) 2026 NextGIS +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or any +# later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, see . + import codecs import os import shutil @@ -42,12 +58,6 @@ def __init__(self, parent=None): self.id_validator = LineEditColorValidator( self.txtId, "^[A-Za-z0-9_]+$", error_tooltip=self.tr("Any text") ) - self.alias_validator = LineEditColorValidator( - self.txtAlias, - "^[A-Za-z0-9_ ]+$", - error_tooltip=self.tr("Any text"), - ) - # vars self.group_info = None self.init_with_existing = False @@ -122,10 +132,6 @@ def validate(self, group_id, group_alias, group_icon): checks_correct = [ (self.id_validator, "Please, enter correct value for group id"), - ( - self.alias_validator, - "Please, enter correct value for group alias", - ), ] for val, comment in checks_correct: diff --git a/src/quick_map_services/group_info.py b/src/quick_map_services/group_info.py index 810d7ac..8eaccc6 100644 --- a/src/quick_map_services/group_info.py +++ b/src/quick_map_services/group_info.py @@ -36,7 +36,6 @@ def __init__( alias=None, icon=None, file_path=None, - menu=None, category=None, ): # general @@ -47,6 +46,5 @@ def __init__( # internal self.file_path = file_path - self.menu = menu self.category = category diff --git a/src/quick_map_services/groups_list.py b/src/quick_map_services/groups_list.py index 500e9bc..54f7325 100644 --- a/src/quick_map_services/groups_list.py +++ b/src/quick_map_services/groups_list.py @@ -27,13 +27,9 @@ from pathlib import Path from typing import List -from qgis.PyQt.QtGui import QIcon -from qgis.PyQt.QtWidgets import QMenu - from quick_map_services.config_reader_helper import ConfigReaderHelper from quick_map_services.core import utils from quick_map_services.core.logging import logger -from quick_map_services.custom_translator import CustomTranslator from quick_map_services.group_info import GroupCategory, GroupInfo from quick_map_services.paths_constants import ( ALL_GROUP_PATHS, @@ -57,7 +53,6 @@ def __init__(self, group_paths: List[Path] = ALL_GROUP_PATHS) -> None: :param group_paths: List of directory paths to search for group definition files. """ - self.translator = CustomTranslator() self.paths = group_paths self.groups = {} self._fill_groups_list() @@ -111,7 +106,13 @@ def _read_ini_file( # Extract group metadata group_id = parser.get("general", "id") - group_alias = parser.get("ui", "alias") + default_group_alias = parser.get("ui", "alias") + group_alias = ConfigReaderHelper.try_read_config( + parser, + "ui", + f"alias[{utils.qgis_locale()}]", + default=default_group_alias, + ) icon_file = ConfigReaderHelper.try_read_config( parser, "ui", "icon" ) @@ -119,49 +120,16 @@ def _read_ini_file( os.path.join(root, icon_file) if icon_file else None ) - # Read possible translations - posible_trans = parser.items("ui") - locale = utils.qgis_locale() - for key, val in posible_trans: - if key == f"alias[{locale}]": - self.translator.append(group_alias, val) - break - except Exception: logger.exception( f"Failed to parse group INI file: {ini_full_path}" ) return - # Create QMenu and GroupInfo - group_menu = QMenu(self.tr(group_alias)) - group_menu.setIcon(QIcon(group_icon_path)) - self.groups[group_id] = GroupInfo( group_id, group_alias, group_icon_path, ini_full_path, - group_menu, category, ) - - def get_group_menu(self, group_id: str) -> QMenu: - """ - Retrieve or create a QMenu for the specified group. - - :param group_id: Unique identifier of the group. - - :returns: QMenu instance associated with the group. - """ - if group_id in self.groups: - return self.groups[group_id].menu - else: - info = GroupInfo(group_id=group_id, menu=QMenu(group_id)) - self.groups[group_id] = info - return info.menu - - # noinspection PyMethodMayBeStatic - def tr(self, message): - # noinspection PyTypeChecker,PyArgumentList,PyCallByClass - return self.translator.translate("QuickMapServices", message) diff --git a/src/quick_map_services/gui/user_groups_box.py b/src/quick_map_services/gui/user_groups_box.py index bd5d832..3ac3778 100644 --- a/src/quick_map_services/gui/user_groups_box.py +++ b/src/quick_map_services/gui/user_groups_box.py @@ -1,4 +1,21 @@ +# NextGIS QuickMapServices +# Copyright (C) 2026 NextGIS +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or any +# later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, see . + import shutil +from copy import deepcopy from pathlib import Path from typing import Optional @@ -20,6 +37,7 @@ from quick_map_services.data_sources_model import DSManagerModel from quick_map_services.group_edit_dialog import GroupEditDialog +from quick_map_services.group_info import GroupInfo from quick_map_services.groups_list import GroupsList from quick_map_services.paths_constants import USER_GROUPS_PATH @@ -181,10 +199,13 @@ def on_copy(self) -> None: ) if select_group_dialog.exec() == QDialog.DialogCode.Accepted: - group_info = self.ds_model.data( - groups_list_view.currentIndex(), Qt.ItemDataRole.UserRole + group_info: GroupInfo = deepcopy( + self.ds_model.data( + groups_list_view.currentIndex(), + Qt.ItemDataRole.UserRole, + ) ) - group_info.id += "_copy" + group_info.id = f"{group_info.id}_copy" edit_dialog = GroupEditDialog() edit_dialog.setWindowTitle(self.tr("Create group from existing")) edit_dialog.fill_group_info(group_info) diff --git a/src/quick_map_services/gui/user_services_box.py b/src/quick_map_services/gui/user_services_box.py index 8871196..bbf186c 100644 --- a/src/quick_map_services/gui/user_services_box.py +++ b/src/quick_map_services/gui/user_services_box.py @@ -1,10 +1,28 @@ +# NextGIS QuickMapServices +# Copyright (C) 2026 NextGIS +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or any +# later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, see . + import shutil +from copy import deepcopy from pathlib import Path from typing import Optional from qgis.core import QgsApplication from qgis.PyQt import uic from qgis.PyQt.QtCore import Qt +from qgis.PyQt.QtGui import QIcon from qgis.PyQt.QtWidgets import ( QDialog, QGroupBox, @@ -16,6 +34,7 @@ QWidget, ) +from quick_map_services.data_source_info import DataSourceInfo from quick_map_services.data_sources_list import DataSourcesList from quick_map_services.data_sources_model import DSManagerModel from quick_map_services.ds_edit_dialog import DsEditDialog @@ -70,7 +89,10 @@ def fill_list(self) -> None: self.lstServices.clear() ds_list = DataSourcesList([USER_DATA_SOURCES_PATH]) for ds in ds_list.data_sources.values(): - item = QListWidgetItem(ds.action.icon(), ds.action.text()) + item = QListWidgetItem( + QIcon(ds.icon_path), + ds.alias, + ) item.setData(Qt.ItemDataRole.UserRole, ds) self.lstServices.addItem(item) @@ -173,13 +195,16 @@ def on_copy(self) -> None: ) if select_data_sources_dialog.exec() == QDialog.DialogCode.Accepted: - data_source = self.ds_model.data( - list_view.currentIndex(), Qt.ItemDataRole.UserRole + data_source: DataSourceInfo = deepcopy( + self.ds_model.data( + list_view.currentIndex(), + Qt.ItemDataRole.UserRole, + ) ) - data_source.id += "_copy" + data_source.id = f"{data_source.id}_copy" edit_dialog = DsEditDialog() edit_dialog.setWindowTitle(self.tr("Create service from existing")) - edit_dialog.fill_ds_info(data_source) + edit_dialog.set_ds_info_for_copy(data_source) if edit_dialog.exec() == QDialog.DialogCode.Accepted: self.fill_list() self.ds_model.reset_model() diff --git a/src/quick_map_services/icon.png b/src/quick_map_services/icon.png deleted file mode 100644 index cd275ff..0000000 Binary files a/src/quick_map_services/icon.png and /dev/null differ diff --git a/src/quick_map_services/icons/material/layers_48dp_FFFFFF_FILL0_wght400_GRAD0_opsz48.svg b/src/quick_map_services/icons/material/layers_48dp_FFFFFF_FILL0_wght400_GRAD0_opsz48.svg new file mode 100644 index 0000000..6ff1192 --- /dev/null +++ b/src/quick_map_services/icons/material/layers_48dp_FFFFFF_FILL0_wght400_GRAD0_opsz48.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/quick_map_services/notifier/message_bar_notifier.py b/src/quick_map_services/notifier/message_bar_notifier.py index 523be3c..1a1ef41 100644 --- a/src/quick_map_services/notifier/message_bar_notifier.py +++ b/src/quick_map_services/notifier/message_bar_notifier.py @@ -1,3 +1,19 @@ +# NextGIS QuickMapServices +# Copyright (C) 2026 NextGIS +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or any +# later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, see . + import re import uuid from typing import TYPE_CHECKING, List, Optional @@ -61,6 +77,7 @@ def display_message( *, level: Qgis.MessageLevel = Qgis.MessageLevel.Info, widgets: Optional[List[QWidget]] = None, + duration: int = 0, **kwargs, # noqa: ANN003, ARG002 ) -> str: """Display a message to the user via the QGIS message bar. @@ -68,6 +85,7 @@ def display_message( :param message: The message to display. :param level: The message level as Qgis.MessageLevel. :param widgets: Custom widgets for message. + :param duration: Timeout in seconds; 0 requires manual dismissal. :return: An identifier for the displayed message. """ custom_widgets = widgets if widgets else [] @@ -79,7 +97,7 @@ def display_message( custom_widget.setParent(widget) widget.layout().addWidget(custom_widget) - item = message_bar.pushWidget(widget, level) + item = message_bar.pushWidget(widget, level, duration) item.setObjectName("QmsMessageBarItem") message_id = str(uuid.uuid4()) item.setProperty("QmsMessageId", message_id) diff --git a/src/quick_map_services/notifier/notifier_interface.py b/src/quick_map_services/notifier/notifier_interface.py index acc0db5..c0cd194 100644 --- a/src/quick_map_services/notifier/notifier_interface.py +++ b/src/quick_map_services/notifier/notifier_interface.py @@ -1,3 +1,19 @@ +# NextGIS QuickMapServices +# Copyright (C) 2026 NextGIS +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or any +# later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, see . + from abc import abstractmethod from qgis.core import Qgis @@ -19,12 +35,14 @@ def display_message( message: str, *, level: Qgis.MessageLevel = Qgis.MessageLevel.Info, + duration: int = 0, **kwargs, # noqa: ANN003 ) -> str: """Display a message to the user. :param message: The message to display. :param level: The message level as Qgis.MessageLevel. + :param duration: Timeout in seconds; 0 requires manual dismissal. :return: An identifier for the displayed message. """ ... diff --git a/src/quick_map_services/qgis_map_helpers.py b/src/quick_map_services/qgis_map_helpers.py index 8669b57..58fa450 100644 --- a/src/quick_map_services/qgis_map_helpers.py +++ b/src/quick_map_services/qgis_map_helpers.py @@ -1,3 +1,19 @@ +# NextGIS QuickMapServices +# Copyright (C) 2026 NextGIS +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or any +# later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, see . + import random from typing import Optional from urllib import parse @@ -19,6 +35,7 @@ from quick_map_services.core.exceptions import QmsError from quick_map_services.core.logging import logger from quick_map_services.core.settings import QmsSettings +from quick_map_services.data_source_info import DataSourceInfo from quick_map_services.quick_map_services_interface import ( QuickMapServicesInterface, ) @@ -32,65 +49,86 @@ service_layers = [] -def add_layer_to_map(ds): +def add_data_source_to_map(data_source: DataSourceInfo) -> None: + """Add a local data source to the current QGIS project. + + :param data_source: Local data source selected by the user. """ - Adds a layer to the current QGIS project based on the datasource config. + try: + add_layer_to_map(data_source) + except Exception as error: + logger.exception( + "An error occurred while adding data source to the map" + ) + QuickMapServicesInterface.instance().notifier.display_exception(error) + + +def add_layer_to_map( + data_source: DataSourceInfo, + qms_id: Optional[int] = None, +) -> None: + """Add a layer to the current QGIS project from a data source. Supports TMS, WMS, WFS, GDAL, and GeoJSON formats. Sets attribution, projection, and correct insertion position in the layer tree. - :param ds: Datasource description with all needed properties - :type ds: DataSourceInfo + :param data_source: Data source configuration. + :param qms_id: Identifier of the service in the Web-QMS catalog. """ layers4add = [] # === TMS LAYERS === - if ds.type.lower() == KNOWN_DRIVERS.TMS.lower(): + if data_source.type.lower() == KNOWN_DRIVERS.TMS.lower(): # Use alternative TMS URL if available - if ds.alt_tms_urls: - tms_url = ds.alt_tms_urls[ - random.randint(0, len(ds.alt_tms_urls) - 1) # noqa: S311 # nosec B311 + if data_source.alt_tms_urls: + tms_url = data_source.alt_tms_urls[ + random.randint( # noqa: S311 # nosec B311 + 0, len(data_source.alt_tms_urls) - 1 + ) ] else: - tms_url = ds.tms_url + tms_url = data_source.tms_url service_url = tms_url.replace("=", "%3D").replace("&", "%26") - if ds.tms_y_origin_top is not None and ds.tms_y_origin_top == False: + if ( + data_source.tms_y_origin_top is not None + and data_source.tms_y_origin_top == False + ): service_url = service_url.replace("{y}", "{-y}") # Construct TMS URI for QGIS qgis_tms_uri = "type=xyz&zmin={0}&zmax={1}&url={2}".format( - ds.tms_zmin if ds.tms_zmin is not None else 0, - ds.tms_zmax if ds.tms_zmax is not None else 18, + data_source.tms_zmin if data_source.tms_zmin is not None else 0, + data_source.tms_zmax if data_source.tms_zmax is not None else 18, service_url, ) # Create and configure TMS raster layer layer = QgsRasterLayer( - qgis_tms_uri, ds.alias, KNOWN_DRIVERS.WMS.lower() + qgis_tms_uri, data_source.alias, KNOWN_DRIVERS.WMS.lower() ) set_tile_layer_proj( layer, - ds.tms_epsg_crs_id, - ds.tms_postgis_crs_id, - ds.tms_custom_proj, + data_source.tms_epsg_crs_id, + data_source.tms_postgis_crs_id, + data_source.tms_custom_proj, ) layers4add.append(layer) # === GDAL LAYERS === - if ds.type.lower() == KNOWN_DRIVERS.GDAL.lower(): - layer = QgsRasterLayer(ds.gdal_source_file, ds.alias) + if data_source.type.lower() == KNOWN_DRIVERS.GDAL.lower(): + layer = QgsRasterLayer(data_source.gdal_source_file, data_source.alias) layers4add.append(layer) # === WMS LAYERS === - if ds.type.lower() == KNOWN_DRIVERS.WMS.lower(): + if data_source.type.lower() == KNOWN_DRIVERS.WMS.lower(): qgis_wms_uri = "" - if ds.wms_params: - qgis_wms_uri += ds.wms_params - if ds.wms_layers: - layers = ds.wms_layers.split(",") + if data_source.wms_params: + qgis_wms_uri += data_source.wms_params + if data_source.wms_layers: + layers = data_source.wms_layers.split(",") if layers: - if ds.wms_turn_over: + if data_source.wms_turn_over: layers.reverse() qgis_wms_uri += ( "&layers=" @@ -99,22 +137,24 @@ def add_layer_to_map(ds): ) qgis_wms_uri += ( "&url=" - + ds.wms_url + + data_source.wms_url + "?" - + ds.wms_url_params.replace("=", "%3D").replace("&", "%26") + + data_source.wms_url_params.replace("=", "%3D").replace( + "&", "%26" + ) ) layer = QgsRasterLayer( - qgis_wms_uri, ds.alias, KNOWN_DRIVERS.WMS.lower() + qgis_wms_uri, data_source.alias, KNOWN_DRIVERS.WMS.lower() ) layers4add.append(layer) # === WFS LAYERS === - if ds.type.lower() == KNOWN_DRIVERS.WFS.lower(): - qgis_wfs_uri_base = ds.wfs_url + if data_source.type.lower() == KNOWN_DRIVERS.WFS.lower(): + qgis_wfs_uri_base = data_source.wfs_url - if ds.wfs_params is not None: - qgis_wfs_uri_base += ds.wfs_params + if data_source.wfs_params is not None: + qgis_wfs_uri_base += data_source.wfs_params o = parse.urlparse(qgis_wfs_uri_base) request_attrs = dict(parse.parse_qsl(o.query)) @@ -123,12 +163,14 @@ def add_layer_to_map(ds): for k, v in request_attrs.items(): new_request_attrs[k.upper()] = v - if ds.wfs_epsg is not None: - new_request_attrs["SRSNAME"] = "EPSG:{0}".format(ds.wfs_epsg) + if data_source.wfs_epsg is not None: + new_request_attrs["SRSNAME"] = "EPSG:{0}".format( + data_source.wfs_epsg + ) layers = [] - if len(ds.wfs_layers) > 0: - layers.extend(ds.wfs_layers) + if len(data_source.wfs_layers) > 0: + layers.extend(data_source.wfs_layers) else: layers_str = request_attrs.get("TYPENAME", "") layers.extend(layers_str.split()) @@ -143,17 +185,21 @@ def add_layer_to_map(ds): qgis_wfs_uri = parse.urlunparse(url_parts) layer = QgsVectorLayer( - qgis_wfs_uri, "%s - %s" % (ds.alias, layer_name), "WFS" + qgis_wfs_uri, + "%s - %s" % (data_source.alias, layer_name), + "WFS", ) layers4add.append(layer) # === GEOJSON LAYERS === - if ds.type.lower() == KNOWN_DRIVERS.GEOJSON.lower(): - layer = QgsVectorLayer(ds.geojson_url, ds.alias, "ogr") + if data_source.type.lower() == KNOWN_DRIVERS.GEOJSON.lower(): + layer = QgsVectorLayer( + data_source.geojson_url, data_source.alias, "ogr" + ) layers4add.append(layer) # === MVT LAYERS === - if ds.type.lower() == KNOWN_DRIVERS.MVT.lower(): + if data_source.type.lower() == KNOWN_DRIVERS.MVT.lower(): if QgsVectorTileLayer is None: user_message = QgsApplication.translate( "QuickMapServices", @@ -168,27 +214,35 @@ def add_layer_to_map(ds): uri = QgsDataSourceUri() uri.setParam("type", "xyz") - uri.setParam("styleUrl", ds.mvt_style_url) - uri.setParam("url", ds.mvt_url) + uri.setParam("styleUrl", data_source.mvt_style_url) + uri.setParam("url", data_source.mvt_url) uri.setParam( - "zmin", str(ds.mvt_zmin) if ds.mvt_zmin is not None else "0" + "zmin", + str(data_source.mvt_zmin) + if data_source.mvt_zmin is not None + else "0", ) uri.setParam( - "zmax", str(ds.mvt_zmax) if ds.mvt_zmax is not None else "14" + "zmax", + str(data_source.mvt_zmax) + if data_source.mvt_zmax is not None + else "14", ) encoded_uri = uri.encodedUri() if isinstance(encoded_uri, QByteArray): encoded_uri = encoded_uri.data().decode("utf-8") - layer = QgsVectorTileLayer(encoded_uri, ds.alias) + layer = QgsVectorTileLayer(encoded_uri, data_source.alias) layer.loadDefaultStyle() layers4add.append(layer) # === ADD LAYERS TO PROJECT === for layer in layers4add: if not layer.isValid(): - error_message = f"Layer '{ds.alias}' can't be added to the map!" + error_message = ( + f"Layer '{data_source.alias}' can't be added to the map!" + ) QuickMapServicesInterface.instance().notifier.display_message( error_message, level=Qgis.MessageLevel.Critical, @@ -197,11 +251,11 @@ def add_layer_to_map(ds): # Set attribs if Qgis.versionInt() >= QGIS_3_38: server_properties = layer.serverProperties() - server_properties.setAttribution(ds.copyright_text) - server_properties.setAttributionUrl(ds.copyright_link) + server_properties.setAttribution(data_source.copyright_text) + server_properties.setAttributionUrl(data_source.copyright_link) else: - layer.setAttribution(ds.copyright_text) - layer.setAttributionUrl(ds.copyright_link) + layer.setAttribution(data_source.copyright_text) + layer.setAttributionUrl(data_source.copyright_link) # Insert layer toc_root = QgsProject.instance().layerTreeRoot() @@ -213,7 +267,7 @@ def add_layer_to_map(ds): ): toc_root = selected_node - if ds.type.lower() in ( + if data_source.type.lower() in ( KNOWN_DRIVERS.WMS.lower(), KNOWN_DRIVERS.TMS.lower(), KNOWN_DRIVERS.MVT.lower(), @@ -224,6 +278,9 @@ def add_layer_to_map(ds): else: position = 0 # insert to top + if qms_id is not None: + layer.setCustomProperty("qms_id", qms_id) + QgsProject.instance().addMapLayer(layer, False) toc_root.insertLayer(position, layer) @@ -234,10 +291,10 @@ def add_layer_to_map(ds): settings = QmsSettings() if settings.enable_otf_3857 and ( ( - ds.type.lower() == KNOWN_DRIVERS.TMS.lower() - and ds.tms_epsg_crs_id == 3857 + data_source.type.lower() == KNOWN_DRIVERS.TMS.lower() + and data_source.tms_epsg_crs_id == 3857 ) - or ds.type.lower() == KNOWN_DRIVERS.MVT.lower() + or data_source.type.lower() == KNOWN_DRIVERS.MVT.lower() ): crs_3857 = QgsCoordinateReferenceSystem.fromEpsgId(3857) iface.mapCanvas().setDestinationCrs(crs_3857) diff --git a/src/quick_map_services/qms_external_api_python/api/api_v1.py b/src/quick_map_services/qms_external_api_python/api/api_v1.py index 23eb8b0..0769f8a 100644 --- a/src/quick_map_services/qms_external_api_python/api/api_v1.py +++ b/src/quick_map_services/qms_external_api_python/api/api_v1.py @@ -1,3 +1,19 @@ +# NextGIS QuickMapServices +# Copyright (C) 2026 NextGIS +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or any +# later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, see . + from quick_map_services.qms_external_api_python.api.api_base import ( ApiClient, ) @@ -12,7 +28,6 @@ def get_geoservices( epsg_filter=None, search_str=None, intersects_boundary=None, - cumulative_status=None, limit=None, offset=None, ): @@ -22,7 +37,6 @@ def get_geoservices( :param epsg: EPSG code of geoservice CRS - any integer. Example: 4326, 3857 :param search_str: Search name or description. Examples: 'osm', 'satellite', 'transport' :param intersects_boundary: Geom (WKT or EWKT format) for filter by intersects with boundary - :param cumulative_status: Status of service: ['works' | 'problematic' | 'failed'] :return: List of geoservices """ sub_url = "geoservices/" @@ -35,8 +49,6 @@ def get_geoservices( params["search"] = search_str if intersects_boundary: params["intersects_boundary"] = intersects_boundary - if cumulative_status: - params["cumulative_status"] = cumulative_status if limit: params["limit"] = limit if offset is not None: diff --git a/src/quick_map_services/qms_service_toolbox.py b/src/quick_map_services/qms_service_toolbox.py index ae1bc8b..6e7e65f 100644 --- a/src/quick_map_services/qms_service_toolbox.py +++ b/src/quick_map_services/qms_service_toolbox.py @@ -1,3 +1,19 @@ +# NextGIS QuickMapServices +# Copyright (C) 2026 NextGIS +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or any +# later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, see . + import ast import sys from datetime import datetime, timezone @@ -15,7 +31,7 @@ QgsProject, QgsSettings, ) -from qgis.gui import QgsDockWidget +from qgis.gui import QgisInterface, QgsDockWidget from qgis.PyQt import uic from qgis.PyQt.QtCore import ( QByteArray, @@ -33,6 +49,7 @@ QColor, QCursor, QDesktopServices, + QIcon, QImage, QLinearGradient, QPainter, @@ -58,8 +75,13 @@ from quick_map_services.core.constants import PACKAGE_NAME from quick_map_services.core.logging import logger from quick_map_services.core.settings import QmsSettings +from quick_map_services.data_source_info import DataSourceCategory from quick_map_services.data_source_serializer import DataSourceSerializer -from quick_map_services.qgis_map_helpers import add_layer_to_map +from quick_map_services.data_sources_catalog import DataSourcesCatalog +from quick_map_services.qgis_map_helpers import ( + add_data_source_to_map, + add_layer_to_map, +) from quick_map_services.qms_external_api_python.api.api_base import QmsNews from quick_map_services.qms_external_api_python.client import Client from quick_map_services.qms_news import News, NewsLayout @@ -72,8 +94,6 @@ from quick_map_services.ui_kit.buttons.shining import ShiningButton from quick_map_services.ui_kit.icons import material_icon -STATUS_FILTER_ALL = "all" -STATUS_FILTER_ONLY_WORKS = "works" SERVICE_LIST_MODE_FAVORITES = "favorites" SERVICE_LIST_MODE_RECENT = "recent" QT_WIDGET_MAX_SIZE = 16777215 @@ -164,8 +184,8 @@ def add_geoservice_to_map( ) return - ds = DataSourceSerializer.read_from_json(geoservice_info) - add_layer_to_map(ds) + data_source = DataSourceSerializer.read_from_json(geoservice_info) + add_layer_to_map(data_source, qms_id=geoservice["id"]) CachedServices().add_service(geoservice, image_ba) if service_added_callback is not None: service_added_callback() @@ -767,7 +787,13 @@ def get_sorted_favorite_services( class QmsServiceToolbox(QgsDockWidget, FORM_CLASS): - def __init__(self, iface): + """Dock widget for searching and selecting map services.""" + + def __init__(self, iface: QgisInterface) -> None: + """Initialize the search panel. + + :param iface: QGIS application interface. + """ QgsDockWidget.__init__(self, iface.mainWindow()) self.setupUi(self) self.newsFrame.setVisible(False) @@ -776,13 +802,10 @@ def __init__(self, iface): self.search_threads = None # [] self.extent_renderer = RubberBandResultRenderer() self._service_list_mode = self._saved_service_list_mode() + self._data_sources_catalog = DataSourcesCatalog() - self.cmbStatusFilter.addItem(self.tr("All"), STATUS_FILTER_ALL) - self.cmbStatusFilter.addItem( - self.tr("Valid"), STATUS_FILTER_ONLY_WORKS - ) - self.cmbStatusFilter.currentIndexChanged.connect(self.start_search) self.favorites_menu = QMenu(self) + self.contributed_services_menu = QMenu(self) self.main_menu = QMenu(self) self.lstSearchResult.setVerticalScrollMode( QAbstractItemView.ScrollMode.ScrollPerPixel @@ -793,7 +816,7 @@ def __init__(self, iface): if hasattr(self.txtSearch, "setPlaceholderText"): self.txtSearch.setPlaceholderText(self.tr("Search string...")) - self._setup_favorites_button() + self._setup_search_controls() self.delay_timer = QTimer(self) self.delay_timer.setSingleShot(True) @@ -824,7 +847,8 @@ def eventFilter(self, watched, event) -> bool: return super().eventFilter(watched, event) - def _setup_favorites_button(self) -> None: + def _setup_search_controls(self) -> None: + """Create controls displayed alongside the service search field.""" search_layout = QHBoxLayout() search_layout.setContentsMargins(0, 0, 0, 0) search_layout.setSpacing(SEARCH_LAYOUT_SPACING) @@ -833,7 +857,66 @@ def _setup_favorites_button(self) -> None: self.verticalLayout_2.removeWidget(self.txtSearch) search_layout.addWidget(self.txtSearch) + search_height = self.txtSearch.sizeHint().height() + self._setup_contributed_services_button(search_layout, search_height) + self._setup_favorites_button(search_layout, search_height) + self._setup_main_menu_button(search_layout, search_height) + + self.verticalLayout_2.insertLayout(search_index, search_layout) + self.contributed_services_menu.aboutToShow.connect( + self._populate_contributed_services_menu + ) + self.favorites_menu.aboutToShow.connect(self._refresh_favorites_menu) + self.main_menu.aboutToShow.connect(self._refresh_main_menu) + self._refresh_favorites_menu() + self._refresh_main_menu() + def _setup_contributed_services_button( + self, + search_layout: QHBoxLayout, + search_height: int, + ) -> None: + """Create the contributed-services menu button. + + :param search_layout: Layout receiving the button. + :param search_height: Height shared by all search controls. + """ + self.contributed_services_button = QToolButton(self) + self.contributed_services_button.setPopupMode( + QToolButton.ToolButtonPopupMode.InstantPopup + ) + self.contributed_services_button.setToolTip( + self.tr("Browse contributed and user services") + ) + self.contributed_services_button.setMenu( + self.contributed_services_menu + ) + self.contributed_services_button.setIcon( + material_icon( + "layers", + color=UiPaletteHelper.muted_icon_color(self), + size=TOOL_BUTTON_ICON_SIZE, + ) + ) + self.contributed_services_button.setFixedSize( + search_height, + search_height, + ) + self.contributed_services_button.setIconSize( + QSize(TOOL_BUTTON_ICON_SIZE, TOOL_BUTTON_ICON_SIZE) + ) + search_layout.addWidget(self.contributed_services_button) + + def _setup_favorites_button( + self, + search_layout: QHBoxLayout, + search_height: int, + ) -> None: + """Create the favorites and recent-services menu button. + + :param search_layout: Layout receiving the button. + :param search_height: Height shared by all search controls. + """ self.btnFavorites = QToolButton(self) self.btnFavorites.setPopupMode( QToolButton.ToolButtonPopupMode.InstantPopup @@ -841,7 +924,6 @@ def _setup_favorites_button(self) -> None: self.btnFavorites.setMenu(self.favorites_menu) self.btnFavorites.setStyleSheet(self._tool_button_style_sheet()) - search_height = self.txtSearch.sizeHint().height() self.btnFavorites.setFixedSize(search_height, search_height) self.btnFavorites.setIconSize( QSize(TOOL_BUTTON_ICON_SIZE, TOOL_BUTTON_ICON_SIZE) @@ -849,6 +931,16 @@ def _setup_favorites_button(self) -> None: search_layout.addWidget(self.btnFavorites) self._update_service_list_mode_button_icon() + def _setup_main_menu_button( + self, + search_layout: QHBoxLayout, + search_height: int, + ) -> None: + """Create the search panel's main menu button. + + :param search_layout: Layout receiving the button. + :param search_height: Height shared by all search controls. + """ self.main_menu_button = QToolButton(self) self.main_menu_button.setPopupMode( QToolButton.ToolButtonPopupMode.InstantPopup @@ -869,11 +961,31 @@ def _setup_favorites_button(self) -> None: self.main_menu_button.setStyleSheet(self._tool_button_style_sheet()) search_layout.addWidget(self.main_menu_button) - self.verticalLayout_2.insertLayout(search_index, search_layout) - self.favorites_menu.aboutToShow.connect(self._refresh_favorites_menu) - self.main_menu.aboutToShow.connect(self._refresh_main_menu) - self._refresh_favorites_menu() - self._refresh_main_menu() + @pyqtSlot() + def _populate_contributed_services_menu(self) -> None: + """Build the contributed-services menu from the local catalog.""" + self.contributed_services_menu.clear() + self._data_sources_catalog.reload() + hidden_data_source_ids = QmsSettings().hidden_datasource_id_list + groups = self._data_sources_catalog.grouped_services( + DataSourceCategory.all, + hidden_data_source_ids, + ) + for group in groups: + group_menu = self.contributed_services_menu.addMenu( + QIcon(group.info.icon), + group.info.alias, + ) + for data_source in group.data_sources: + action = group_menu.addAction( + QIcon(data_source.icon_path), + data_source.alias, + ) + action.triggered.connect( + lambda _checked, source=data_source: ( + add_data_source_to_map(source) + ) + ) def _update_service_list_mode_button_icon(self) -> None: icon_name = "history" @@ -1355,14 +1467,6 @@ def start_search(self) -> None: geom_filter = None min_search_text_len = 3 - # status filter - status_filter = None - sel_value = self.cmbStatusFilter.itemData( - self.cmbStatusFilter.currentIndex() - ) - if sel_value != STATUS_FILTER_ALL: - status_filter = sel_value - if not self.btnFilterByExtent.isChecked(): # text search search_text = str(self.txtSearch.text()) @@ -1403,7 +1507,6 @@ def start_search(self) -> None: self.one_process_work, parent=self.iface.mainWindow(), geom_filter=geom_filter, - status_filter=status_filter, ) searcher.data_downloaded.connect(self.show_result) searcher.error_occurred.connect(self.show_error) @@ -2315,7 +2418,6 @@ def __init__( mutex: QMutex, parent: Optional[QThread] = None, geom_filter: Optional[str] = None, - status_filter: Optional[str] = None, ) -> None: """ Initialize a thread for performing asynchronous geoservice searches. @@ -2324,14 +2426,11 @@ def __init__( :param mutex: QMutex object to synchronize access to shared resources. :param parent: Optional parent QThread object. :param geom_filter: Optional WKT polygon string to filter search by map extent. - :param status_filter: Optional status filter to limit search results. - :return: None """ super().__init__(parent) self.search_text = search_text self.geom_filter = geom_filter - self.status_filter = status_filter self.searcher = Client() self.mutex = mutex @@ -2351,7 +2450,6 @@ def run(self): results = self.searcher.get_geoservices( search_str=self.search_text, intersects_boundary=self.geom_filter, - cumulative_status=self.status_filter, ) ext_results = [] diff --git a/src/quick_map_services/qms_service_toolbox.ui b/src/quick_map_services/qms_service_toolbox.ui index dbd3eb9..31d9d4e 100644 --- a/src/quick_map_services/qms_service_toolbox.ui +++ b/src/quick_map_services/qms_service_toolbox.ui @@ -92,9 +92,6 @@ - - - diff --git a/src/quick_map_services/quick_map_services.py b/src/quick_map_services/quick_map_services.py index 9c4781e..cc51df0 100644 --- a/src/quick_map_services/quick_map_services.py +++ b/src/quick_map_services/quick_map_services.py @@ -1,35 +1,35 @@ -# -*- coding: utf-8 -*- -""" -/*************************************************************************** - QuickMapServices - A QGIS plugin - Collection of internet map services - ------------------- - begin : 2014-11-21 - git sha : $Format:%H$ - copyright : (C) 2014 by NextGIS - email : info@nextgis.com - ***************************************************************************/ - -/*************************************************************************** - * * - * This program is free software; you can redistribute it and/or modify * - * it under the terms of the GNU General Public License as published by * - * the Free Software Foundation; either version 2 of the License, or * - * (at your option) any later version. * - * * - ***************************************************************************/ -""" +# NextGIS QuickMapServices +# Copyright (C) 2026 NextGIS +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or any +# later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, see . import os.path import sys import xml.etree.ElementTree as ET # nosec B405 -from typing import TYPE_CHECKING, Dict, List, Optional +from typing import TYPE_CHECKING, Iterable, Optional from osgeo import gdal -from qgis.core import Qgis, QgsProject +from qgis.core import Qgis from qgis.gui import QgisInterface -from qgis.PyQt.QtCore import QT_VERSION_STR, QObject, QSysInfo, Qt, QUrl +from qgis.PyQt.QtCore import ( + QT_VERSION_STR, + QCoreApplication, + QObject, + QSysInfo, + Qt, + QUrl, +) from qgis.PyQt.QtGui import QDesktopServices, QIcon from qgis.PyQt.QtWidgets import ( QAction, @@ -44,18 +44,23 @@ from quick_map_services.core.constants import PACKAGE_NAME, PLUGIN_NAME from quick_map_services.core.logging import logger from quick_map_services.core.settings import QmsSettings -from quick_map_services.custom_translator import CustomTranslator -from quick_map_services.data_source_info import DataSourceInfo -from quick_map_services.data_sources_list import DataSourcesList -from quick_map_services.groups_list import GroupsList +from quick_map_services.data_source_info import DataSourceCategory +from quick_map_services.data_sources_catalog import ( + DataSourceGroup, + DataSourcesCatalog, +) from quick_map_services.gui.qms_settings_page import QmsSettingsPageFactory from quick_map_services.notifier.message_bar_notifier import MessageBarNotifier -from quick_map_services.qgis_map_helpers import add_layer_to_map +from quick_map_services.qgis_map_helpers import add_data_source_to_map from quick_map_services.qms_service_toolbox import QmsServiceToolbox from quick_map_services.quick_map_services_interface import ( QuickMapServicesInterface, ) -from quick_map_services.ui_kit.icons import material_icon +from quick_map_services.ui_kit.icons import ( + material_icon, + plugin_icon, + qgis_icon, +) if TYPE_CHECKING: from quick_map_services.notifier.notifier_interface import ( @@ -100,12 +105,13 @@ def __init__(self, parent: Optional[QObject] = None) -> None: # initialize plugin directory self.plugin_dir = os.path.dirname(__file__) - self.custom_translator = CustomTranslator() - # Create the dialog (after translation) and keep reference self.info_dlg = AboutDialog( PACKAGE_NAME, components_path=self.path / "assets/components.json" ) + self.info_dlg.developer_mode_toggle_requested.connect( + self._toggle_developer_mode + ) try: utils.ensure_user_dirs() @@ -129,6 +135,7 @@ def __init__(self, parent: Optional[QObject] = None) -> None: self._notifier = None self.qms_search_action = None self.qms_search_toolbar_action = None + self.data_sources_catalog = DataSourcesCatalog() @property def notifier(self) -> "NotifierInterface": @@ -141,10 +148,15 @@ def notifier(self) -> "NotifierInterface": assert self._notifier is not None, "Notifier is not initialized" # nosec B101 return self._notifier - # noinspection PyMethodMayBeStatic - def tr(self, message): - # noinspection PyTypeChecker,PyArgumentList,PyCallByClass - return self.custom_translator.translate("QuickMapServices", message) + @staticmethod + def tr(message: str) -> str: + """Translate a QuickMapServices user-facing string. + + :param message: Source text to translate. + + :returns: Translated text. + """ + return QCoreApplication.translate("QuickMapServices", message) def _load(self) -> None: """ @@ -156,9 +168,8 @@ def _load(self) -> None: self._notifier = MessageBarNotifier(self) # Create menu - icon_path = self.plugin_dir + "/icons/mActionAddLayer.svg" - self.menu = QMenu(self.tr("QuickMapServices")) - self.menu.setIcon(QIcon(icon_path)) + self.menu = QMenu(PLUGIN_NAME) + self.menu.setIcon(plugin_icon()) self.init_server_panel() self.build_menu_tree() @@ -206,39 +217,40 @@ def set_nearest_scale(self): if nearest_scale != sys.maxsize: self.iface.mapCanvas().zoomScale(nearest_scale) - def set_tms_scales(self): - res = QMessageBox.question( - self.iface.mainWindow(), - self.tr("QuickMapServices"), - self.tr( - "Set SlippyMap scales for current project?\nThe previous settings will be overwritten!" - ), + def _toggle_developer_mode(self) -> None: + """Ask for confirmation and toggle the persistent developer mode.""" + settings = QmsSettings() + is_enabled = settings.is_developer_mode_enabled + question = self.tr( + "Disable developer mode?" + if is_enabled + else "Enable developer mode?" + ) + details = self.tr( + "Developer mode is intended for plugin development and may expose " + "experimental features." + ) + response = QMessageBox.question( + self.info_dlg, + PLUGIN_NAME, + f"{question}\n\n{details}", QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + QMessageBox.StandardButton.No, ) - if res == QMessageBox.StandardButton.Yes: - # set scales - QgsProject.instance().writeEntry( - "Scales", "/ScalesList", self.scales_list - ) - # activate - QgsProject.instance().writeEntry( - "Scales", "/useProjectScales", True - ) - # update in main window - # ???? no way to update: http://hub.qgis.org/issues/11917 + if response != QMessageBox.StandardButton.Yes: + return - def insert_layer(self): - action = self.menu.sender() - ds = action.data() - try: - add_layer_to_map(ds) - except Exception as error: - logger.exception( - "An error occured while adding geoservice to the map" - ) - QuickMapServicesInterface.instance().notifier.display_exception( - error - ) + settings.is_developer_mode_enabled = not is_enabled + message = self.tr( + "Developer mode disabled." + if is_enabled + else "Developer mode enabled." + ) + self.notifier.display_message( + message, + level=Qgis.MessageLevel.Info, + duration=5, + ) def _unload(self) -> None: """ @@ -252,8 +264,7 @@ def _unload(self) -> None: self.menu = None self.toolbutton = None self.service_actions = None - self.ds_list = None - self.groups_list = None + self.data_sources_catalog = None self.service_layers = None if self._qms_settings_page_factory is not None: @@ -270,7 +281,6 @@ def _unload(self) -> None: qms_create_service_action = None qms_banner_action = None set_nearest_scale_act = None - scales_act = None settings_act = None info_act = None @@ -282,22 +292,14 @@ def build_menu_tree(self) -> None: """ self.menu.clear() - self.groups_list = GroupsList() - self.ds_list = DataSourcesList() - - all_groups = utils.collect_groups(self.ds_list.data_sources.values()) - - groups = utils.filter_hidden_data_sources( - all_groups, - QmsSettings().hidden_datasource_id_list, - ) - - sorted_group_ids = utils.sort_group_ids( - groups.keys(), + self.data_sources_catalog.reload() + hidden_data_source_ids = QmsSettings().hidden_datasource_id_list + all_service_groups = self.data_sources_catalog.grouped_services( + DataSourceCategory.all, + hidden_data_source_ids, ) - self._add_qms_section() - self._populate_groups_menu(groups, sorted_group_ids) + self._populate_data_sources_menu(self.menu, all_service_groups) self._add_plugin_actions() def remove_menu_buttons(self): @@ -461,32 +463,31 @@ def open_nextgis_data_url(self) -> None: def _nextgis_data_action_text(self) -> str: return self.tr("Download geodata for your project") - def _populate_groups_menu( + def _populate_data_sources_menu( self, - groups: Dict[str, List[DataSourceInfo]], - sorted_group_ids: List[str], + menu: QMenu, + groups: Iterable[DataSourceGroup], ) -> None: - """ - Populate menu with grouped data sources. + """Populate a menu with grouped data-source actions. - :param groups: Grouped data sources. - :param sorted_group_ids: Ordered group ids. - - :return: None + :param menu: Menu to populate. + :param groups: Ordered groups of visible data sources. """ - for group_id in sorted_group_ids: - group_menu: QMenu = self.groups_list.get_group_menu(group_id) - group_menu.clear() - - for data_source in utils.sort_data_sources(groups[group_id]): - action = data_source.action - if action is None: - continue - - action.triggered.connect(self.insert_layer) - group_menu.addAction(action) - - self.menu.addMenu(group_menu) + for group in groups: + group_menu = menu.addMenu( + QIcon(group.info.icon), + self.tr(group.info.alias), + ) + for data_source in group.data_sources: + action = group_menu.addAction( + QIcon(data_source.icon_path), + self.tr(data_source.alias), + ) + action.triggered.connect( + lambda _checked, source=data_source: ( + add_data_source_to_map(source) + ) + ) def _add_qms_section(self) -> None: """ @@ -533,11 +534,12 @@ def _add_plugin_actions(self) -> None: :return: None """ + self.menu.addSeparator() + if not self.set_nearest_scale_act: - icon_path = f"{self.plugin_dir}/icons/mActionSettings.svg" self.set_nearest_scale_act = QAction( - QIcon(icon_path), - self.tr("Set proper scale"), + qgis_icon("mActionSetToCanvasScale.svg"), + self.tr("Set nearest SlippyMap scale"), self.iface.mainWindow(), ) self.set_nearest_scale_act.triggered.connect( @@ -547,20 +549,9 @@ def _add_plugin_actions(self) -> None: self.menu.addAction(self.set_nearest_scale_act) - if not self.scales_act: - icon_path = f"{self.plugin_dir}/icons/mActionSettings.svg" - self.scales_act = QAction( - QIcon(icon_path), - self.tr("Set SlippyMap scales"), - self.iface.mainWindow(), - ) - self.scales_act.triggered.connect(self.set_tms_scales) - self.service_actions.append(self.scales_act) - if not self.settings_act: - icon_path = f"{self.plugin_dir}/icons/mActionSettings.svg" self.settings_act = QAction( - QIcon(icon_path), + qgis_icon("iconSettingsConsole.svg"), self.tr("Settings"), self.iface.mainWindow(), ) @@ -570,9 +561,8 @@ def _add_plugin_actions(self) -> None: self.menu.addAction(self.settings_act) if not self.info_act: - icon_path = f"{self.plugin_dir}/icons/mActionAbout.svg" self.info_act = QAction( - QIcon(icon_path), + qgis_icon("mActionPropertiesWidget.svg"), self.tr("About QMS"), self.iface.mainWindow(), ) @@ -583,7 +573,7 @@ def _add_plugin_actions(self) -> None: self._help_action = QAction( QIcon(f"{self.plugin_dir}/icons/qms_logo.svg"), - "QuickMapServices", + PLUGIN_NAME, ) self._help_action.triggered.connect(self.info_dlg.show) diff --git a/src/quick_map_services/quickmapservices_contrib b/src/quick_map_services/quickmapservices_contrib index a3690e1..441ffe6 160000 --- a/src/quick_map_services/quickmapservices_contrib +++ b/src/quick_map_services/quickmapservices_contrib @@ -1 +1 @@ -Subproject commit a3690e1fe68463744c22dfd0218e3257e96b6dd9 +Subproject commit 441ffe6644db24f0f799ea0d9c633439db7c9df1