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