├── src └── quick_map_services │ ├── notifier │ ├── __init__.py │ ├── notifier_interface.py │ └── message_bar_notifier.py │ ├── shared │ ├── __init__.py │ └── qobject_metaclass.py │ ├── qms_external_api_python │ ├── __init__.py │ ├── api │ │ ├── __init__.py │ │ ├── geoservice_types.py │ │ ├── api_base.py │ │ └── api_v1.py │ └── client.py │ ├── gui │ ├── __init__.py │ ├── editor_widget_geojson.ui │ ├── line_edit_color_validator.py │ ├── editor_widget_gdal.ui │ ├── editor_widget_gdal.py │ ├── editor_widget_geojson.py │ ├── editor_widget_wms.ui │ ├── editor_widget_wms.py │ ├── editor_widget_wfs.py │ ├── editor_widget_wfs.ui │ ├── user_services_box.ui │ ├── user_groups_box.ui │ ├── editor_widget_tms.ui │ ├── editor_widget_tms.py │ ├── user_services_box.py │ ├── user_groups_box.py │ └── qms_settings_page.py │ ├── icon.png │ ├── groups │ ├── osm │ │ └── osm.ini │ └── nasa │ │ ├── nasa.ini │ │ └── nasa_logo.png │ ├── icons │ ├── fire.png │ ├── news.png │ ├── mapservices.png │ ├── nextgis_logo.svg │ └── qms_logo.svg │ ├── core │ ├── constants.py │ ├── utils.py │ ├── compat.py │ ├── logging.py │ └── exceptions.py │ ├── help │ ├── source │ │ ├── index.rst │ │ └── conf.py │ └── make.bat │ ├── data_sources │ └── osm_mapnik │ │ └── metadata.ini │ ├── scales.xml │ ├── fixed_config_parser.py │ ├── config_reader_helper.py │ ├── qms_news.py │ ├── supported_drivers.py │ ├── gdal_utils.py │ ├── group_info.py │ ├── singleton.py │ ├── custom_translator.py │ ├── quick_map_services_plugin_stub.py │ ├── __init__.py │ ├── data_source_info.py │ ├── data_sources_list.py │ ├── group_edit_dialog.ui │ ├── file_selection_widget.py │ ├── rb_result_renderer.py │ ├── quick_map_services_interface.py │ ├── qms_service_toolbox.ui │ ├── groups_list.py │ ├── extra_sources.py │ ├── ds_edit_dialog.ui │ ├── about_dialog_base.ui │ └── group_edit_dialog.py ├── COMPONENTS ├── .pre-commit-config.yaml ├── .github └── ISSUE_TEMPLATE │ ├── config.yml │ ├── 02_feature_request.yaml │ └── 01_bug_report.yaml ├── README.md ├── pyproject.toml └── .gitignore /src/quick_map_services/notifier/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/quick_map_services/shared/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/quick_map_services/qms_external_api_python/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/quick_map_services/qms_external_api_python/api/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/quick_map_services/gui/__init__.py: -------------------------------------------------------------------------------- 1 | __author__ = "yellow" 2 | __license__ = "" 3 | __date__ = "2014" 4 | -------------------------------------------------------------------------------- /COMPONENTS: -------------------------------------------------------------------------------- 1 |

Some icons from QGIS: QGIS GitHub

-------------------------------------------------------------------------------- /src/quick_map_services/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nextgis/quickmapservices/HEAD/src/quick_map_services/icon.png -------------------------------------------------------------------------------- /src/quick_map_services/groups/osm/osm.ini: -------------------------------------------------------------------------------- 1 | [general] 2 | id = osm 3 | 4 | [ui] 5 | alias = OSM 6 | icon = osm.svg 7 | 8 | 9 | -------------------------------------------------------------------------------- /src/quick_map_services/groups/nasa/nasa.ini: -------------------------------------------------------------------------------- 1 | [general] 2 | id = nasa 3 | 4 | [ui] 5 | alias = NASA 6 | icon = nasa_logo.png 7 | 8 | 9 | -------------------------------------------------------------------------------- /src/quick_map_services/icons/fire.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nextgis/quickmapservices/HEAD/src/quick_map_services/icons/fire.png -------------------------------------------------------------------------------- /src/quick_map_services/icons/news.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nextgis/quickmapservices/HEAD/src/quick_map_services/icons/news.png -------------------------------------------------------------------------------- /src/quick_map_services/core/constants.py: -------------------------------------------------------------------------------- 1 | PACKAGE_NAME = "quick_map_services" 2 | PLUGIN_NAME = "QuickMapServices" 3 | COMPANY_NAME = "NextGIS" 4 | -------------------------------------------------------------------------------- /src/quick_map_services/icons/mapservices.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nextgis/quickmapservices/HEAD/src/quick_map_services/icons/mapservices.png -------------------------------------------------------------------------------- /src/quick_map_services/groups/nasa/nasa_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nextgis/quickmapservices/HEAD/src/quick_map_services/groups/nasa/nasa_logo.png -------------------------------------------------------------------------------- /src/quick_map_services/qms_external_api_python/client.py: -------------------------------------------------------------------------------- 1 | from .api.api_v1 import ApiClientV1 as Client 2 | from .api.geoservice_types import GeoServiceType 3 | 4 | __all__ = [Client, GeoServiceType] 5 | -------------------------------------------------------------------------------- /src/quick_map_services/qms_external_api_python/api/geoservice_types.py: -------------------------------------------------------------------------------- 1 | class GeoServiceType: 2 | TMS = "tms" 3 | WMS = "wms" 4 | WFS = "wfs" 5 | GeoJSON = "geojson" 6 | 7 | enum = [TMS, WMS, WFS, GeoJSON] 8 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v6.0.0 4 | hooks: 5 | - id: check-toml 6 | 7 | - repo: https://github.com/astral-sh/ruff-pre-commit 8 | rev: v0.14.0 9 | hooks: 10 | - id: ruff 11 | args: [--fix] 12 | - id: ruff-format 13 | -------------------------------------------------------------------------------- /src/quick_map_services/shared/qobject_metaclass.py: -------------------------------------------------------------------------------- 1 | from abc import ABCMeta 2 | 3 | from qgis.PyQt.QtCore import QObject 4 | 5 | 6 | class QObjectMetaClass(ABCMeta, type(QObject)): 7 | """Defines a metaclass for QObject-based classes. 8 | 9 | QObjectMetaClass: A metaclass that combines ABCMeta (for abstract base 10 | classes) and the metaclass of QObject, allowing for the creation of 11 | abstract Qt objects. 12 | """ 13 | -------------------------------------------------------------------------------- /src/quick_map_services/help/source/index.rst: -------------------------------------------------------------------------------- 1 | .. QuickMapServices documentation master file, created by 2 | sphinx-quickstart on Sun Feb 12 17:11:03 2012. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | Welcome to QuickMapServices's documentation! 7 | ============================================ 8 | 9 | Contents: 10 | 11 | .. toctree:: 12 | :maxdepth: 2 13 | 14 | Indices and tables 15 | ================== 16 | 17 | * :ref:`genindex` 18 | * :ref:`modindex` 19 | * :ref:`search` 20 | 21 | -------------------------------------------------------------------------------- /src/quick_map_services/data_sources/osm_mapnik/metadata.ini: -------------------------------------------------------------------------------- 1 | [general] 2 | id = osm_mapnik 3 | type = TMS 4 | is_contrib = False 5 | 6 | [ui] 7 | group = osm 8 | alias = OSM Standard 9 | icon = osm.svg 10 | 11 | [license] 12 | name = CC-BY-SA 2.0 13 | link = https://creativecommons.org/licenses/by-sa/2.0/ 14 | copyright_text =© OpenStreetMap contributors, CC-BY-SA 15 | copyright_link = https://www.openstreetmap.org/copyright 16 | terms_of_use = https://operations.osmfoundation.org/policies/tiles/ 17 | 18 | [tms] 19 | url = https://tile.openstreetmap.org/{z}/{x}/{y}.png 20 | zmin = 0 21 | zmax = 19 22 | y_origin_top = 1 23 | epsg_crs_id = 3857 24 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: A service is not working 4 | # There must be a link to make this option valid for GitHub 5 | url: https://qms.nextgis.com/?utm_source=qgis_plugin&utm_medium=issues&utm_campaign=constant&utm_term=quick_map_services 6 | about: | 7 | Use the feedback form on the website. 8 | Each map service is maintained by its own author (aka submitter). The form notifies the service author that it’s not working. Please include any details about symptoms or possible fixes. 9 | - name: Commercial support 10 | url: https://nextgis.com/contact/?utm_source=qgis_plugin&utm_medium=issues_commercial&utm_campaign=constant&utm_term=quick_map_services 11 | about: We provide custom development and support for this software. Contact us to discuss options! -------------------------------------------------------------------------------- /src/quick_map_services/scales.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /src/quick_map_services/gui/editor_widget_geojson.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | Form 4 | 5 | 6 | 7 | 0 8 | 0 9 | 395 10 | 169 11 | 12 | 13 | 14 | Form 15 | 16 | 17 | 18 | QFormLayout::ExpandingFieldsGrow 19 | 20 | 21 | 22 | 23 | URL 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/02_feature_request.yaml: -------------------------------------------------------------------------------- 1 | name: Feature request 2 | description: Suggest a feature idea for QuickMapServices plugin. 3 | labels: 4 | - 'feature request' 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: | 9 | Thank you for taking the time to fill out an issue, this template is meant for any feature suggestions related to the QuickMapServices plugin. 10 | If you require help with the NextGIS software, we ask that you join [our Discord](https://discord.gg/V3G9snXGy5) 11 | - type: textarea 12 | id: proposal 13 | attributes: 14 | label: Feature description 15 | description: | 16 | A clear and concise explanation of your feature idea. For example: QuickMapServices would be even better if [...] 17 | validations: 18 | required: true 19 | - type: textarea 20 | id: anything-else 21 | attributes: 22 | label: Anything else? 23 | description: | 24 | Add any other context or screenshots about the feature request here. 25 | -------------------------------------------------------------------------------- /src/quick_map_services/fixed_config_parser.py: -------------------------------------------------------------------------------- 1 | from configparser import RawConfigParser 2 | 3 | DEFAULTSECT = "DEFAULT" 4 | 5 | 6 | class FixedConfigParser(RawConfigParser): 7 | """ 8 | Unicode writer fix for ConfigParser 9 | """ 10 | 11 | def write(self, fp, space_around_delimiters: bool = True): 12 | """Write an .ini-format representation of the configuration state.""" 13 | if self._defaults: 14 | fp.write("[%s]\n" % DEFAULTSECT) 15 | for key, value in self._defaults.items(): 16 | fp.write("%s = %s\n" % (key, str(value).replace("\n", "\n\t"))) 17 | fp.write("\n") 18 | for section in self._sections: 19 | fp.write("[%s]\n" % section) 20 | for key, value in self._sections[section].items(): 21 | if key == "__name__": 22 | continue 23 | if (value is not None) or (self._optcre == self.OPTCRE): 24 | key = " = ".join((key, str(value).replace("\n", "\n\t"))) 25 | fp.write("%s\n" % (key)) 26 | fp.write("\n") 27 | -------------------------------------------------------------------------------- /src/quick_map_services/gui/line_edit_color_validator.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | 4 | class LineEditColorValidator: 5 | def __init__( 6 | self, line_edit, re_mask, error_color="#f6989d", error_tooltip="" 7 | ): 8 | self.error_color = error_color 9 | self.error_msg = error_tooltip 10 | self.line_edit = line_edit 11 | self.line_edit.textChanged.connect(self.on_changed) 12 | self.re_mask = re_mask 13 | self.re_comp = re.compile(self.re_mask) 14 | self.on_changed() # init 15 | 16 | def is_valid(self): 17 | text = self.line_edit.text() 18 | return self.re_comp.match(text) 19 | 20 | def on_changed(self, text=""): 21 | if self.is_valid(): 22 | self.set_normal_style() 23 | else: 24 | self.set_error_style() 25 | 26 | def set_normal_style(self): 27 | self.line_edit.setStyleSheet("") 28 | self.line_edit.setToolTip("") 29 | 30 | def set_error_style(self): 31 | self.line_edit.setStyleSheet( 32 | "QLineEdit { background-color: %s }" % self.error_color 33 | ) 34 | self.line_edit.setToolTip(self.error_msg) 35 | -------------------------------------------------------------------------------- /src/quick_map_services/gui/editor_widget_gdal.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | Form 4 | 5 | 6 | 7 | 0 8 | 0 9 | 395 10 | 169 11 | 12 | 13 | 14 | Form 15 | 16 | 17 | 18 | QFormLayout::ExpandingFieldsGrow 19 | 20 | 21 | 22 | 23 | GDAL File 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | FileSelectionWidget 35 | QLineEdit 36 |
quick_map_services.file_selection_widget
37 |
38 |
39 | 40 | 41 |
42 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # QuickMapServices 2 | 3 | A QGIS plugin. Add various map services as base layers in one click. 4 | 5 | Important: 6 | - adding copyrights of particular service providers is responsibility of the user 7 | - if you want to add your own service – add them here: https://qms.nextgis.com 8 | 9 | QGIS plugins page: https://plugins.qgis.org/plugins/quick_map_services/ 10 | 11 | ## Discover and use geodata services 12 | 13 | ![qms](https://github.com/nextgis/quickmapservices/assets/101568545/54c40a19-0077-4bfb-abf0-8464e3d194dc) 14 | 15 | ## YouTube 16 | 17 | [![lw_v0GlZzcE](https://github.com/nextgis/quickmapservices/assets/101568545/e0616e45-ac53-42d5-8c65-7533c0a45107)](https://youtu.be/lw_v0GlZzcE) 18 | 19 | ## License 20 | 21 | This program is licensed under GNU GPL v.2 or any later version. 22 | 23 | ## Commercial support 24 | 25 | Need to fix a bug or add a feature to QuickMapServices? 26 | 27 | We provide custom development and support for this software. [Contact us](https://nextgis.com/contact/?utm_source=qgis_plugin&utm_medium=readme_commercial&utm_campaign=constant&utm_term=quick_map_services) to discuss options! 28 | 29 | 30 | [![https://nextgis.com](https://nextgis.com/img/nextgis_x-logo.png)](https://nextgis.com?utm_source=qgis_plugin&utm_medium=readme&utm_campaign=constant&utm_term=quick_map_services) 31 | -------------------------------------------------------------------------------- /src/quick_map_services/gui/editor_widget_gdal.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from qgis.PyQt import uic 4 | from qgis.PyQt.QtWidgets import QMessageBox, QWidget 5 | 6 | FORM_CLASS, _ = uic.loadUiType( 7 | os.path.join(os.path.dirname(__file__), "editor_widget_gdal.ui") 8 | ) 9 | 10 | 11 | class EditorWidgetGdal(QWidget, FORM_CLASS): 12 | def __init__(self, parent=None): 13 | super(EditorWidgetGdal, self).__init__(parent) 14 | self.setupUi(self) 15 | # init icon selector 16 | self.txtGdalFile.set_dialog_ext( 17 | self.tr("GDAL Data Source (*.xml);;All files (*.*)") 18 | ) 19 | self.txtGdalFile.set_dialog_title( 20 | self.tr("Select gdal data source file") 21 | ) 22 | 23 | def feel_form(self, ds_info): 24 | self.ds_info = ds_info 25 | self.txtGdalFile.set_path(self.ds_info.gdal_source_file) 26 | 27 | def feel_ds_info(self, ds_info): 28 | ds_info.gdal_source_file = self.txtGdalFile.get_path() 29 | 30 | def validate(self, ds_info): 31 | if not ds_info: 32 | QMessageBox.critical( 33 | self, 34 | self.tr("Error on save data source"), 35 | self.tr("Please, select GDAL file path"), 36 | ) 37 | return False 38 | 39 | return True 40 | -------------------------------------------------------------------------------- /src/quick_map_services/core/utils.py: -------------------------------------------------------------------------------- 1 | from qgis.core import QgsSettings 2 | from qgis.PyQt.QtCore import QLocale 3 | 4 | from quick_map_services.core.constants import PACKAGE_NAME 5 | 6 | 7 | def locale() -> str: 8 | """Return the current locale code as a two-letter lowercase string. 9 | 10 | :returns: Two-letter lowercase locale code (e.g., "en", "fr"). 11 | :rtype: str 12 | """ 13 | override_locale = QgsSettings().value( 14 | "locale/overrideFlag", defaultValue=False, type=bool 15 | ) 16 | if not override_locale: 17 | locale_full_name = QLocale.system().name() 18 | else: 19 | locale_full_name = QgsSettings().value("locale/userLocale", "") 20 | locale = locale_full_name[0:2].lower() 21 | 22 | return locale if locale.lower() != "c" else "en" 23 | 24 | 25 | def utm_tags(utm_medium: str, *, utm_campaign: str = "constant") -> str: 26 | """Generate a UTM tag string with customizable medium and campaign. 27 | 28 | :param utm_medium: UTM medium value. 29 | :type utm_medium: str 30 | :param utm_campaign: UTM campaign value. 31 | :type utm_campaign: str 32 | :returns: UTM tag string. 33 | :rtype: str 34 | """ 35 | return ( 36 | f"utm_source=qgis_plugin&utm_medium={utm_medium}" 37 | f"&utm_campaign={utm_campaign}&utm_term={PACKAGE_NAME}" 38 | f"&utm_content={locale()}" 39 | ) 40 | -------------------------------------------------------------------------------- /src/quick_map_services/config_reader_helper.py: -------------------------------------------------------------------------------- 1 | __author__ = "yellow" 2 | 3 | 4 | class ConfigReaderHelper(object): 5 | @staticmethod 6 | def try_read_config(parser, section, param, reraise=False, default=None): 7 | try: 8 | val = parser.get(section, param) 9 | except: 10 | if reraise: 11 | raise 12 | else: 13 | val = default 14 | return val 15 | 16 | @staticmethod 17 | def try_read_config_int(parser, section, param, reraise=False): 18 | try: 19 | val = parser.getint(section, param) 20 | except: 21 | if reraise: 22 | raise 23 | else: 24 | val = None 25 | return val 26 | 27 | @staticmethod 28 | def try_read_config_bool(parser, section, param, reraise=False): 29 | try: 30 | val = parser.getboolean(section, param) 31 | except: 32 | if reraise: 33 | raise 34 | else: 35 | val = None 36 | return val 37 | 38 | @staticmethod 39 | def try_read_config_float(parser, section, param, reraise=False): 40 | try: 41 | val = parser.getfloat(section, param) 42 | except: 43 | if reraise: 44 | raise 45 | else: 46 | val = None 47 | return val 48 | -------------------------------------------------------------------------------- /src/quick_map_services/qms_news.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import os 3 | from pathlib import Path 4 | 5 | from quick_map_services.core import utils 6 | 7 | plugin_dir = os.path.dirname(__file__) 8 | 9 | 10 | class News: 11 | """docstring for News""" 12 | 13 | def __init__( 14 | self, qms_news, date_start=None, date_finish=None, icon="news.png" 15 | ) -> None: 16 | super(News, self).__init__() 17 | 18 | html = qms_news.get_text(utils.locale()) 19 | 20 | icon_path = Path(plugin_dir) / "icons" / icon 21 | 22 | self.html = """ 23 | 24 | 25 | 26 |
27 | 28 | 29 | 30 | 31 | 32 |
 {}
33 |
34 | 35 | 36 | """.format(icon_path, html) 37 | self.date_start = date_start 38 | if self.date_start is None: 39 | self.date_start = datetime.datetime.now() 40 | self.date_finish = date_finish 41 | 42 | def is_time_to_show(self): 43 | current_timestamp = datetime.datetime.now().timestamp() 44 | if self.date_start.timestamp() > current_timestamp: 45 | return False 46 | 47 | if self.date_finish is None: 48 | return True 49 | 50 | return self.date_finish.timestamp() > current_timestamp 51 | -------------------------------------------------------------------------------- /src/quick_map_services/gui/editor_widget_geojson.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from qgis.PyQt import uic 4 | from qgis.PyQt.QtWidgets import QMessageBox, QWidget 5 | 6 | from .line_edit_color_validator import LineEditColorValidator 7 | 8 | FORM_CLASS, _ = uic.loadUiType( 9 | os.path.join(os.path.dirname(__file__), "editor_widget_geojson.ui") 10 | ) 11 | 12 | 13 | class EditorWidgetGeoJson(QWidget, FORM_CLASS): 14 | def __init__(self, parent=None): 15 | super(EditorWidgetGeoJson, self).__init__(parent) 16 | self.setupUi(self) 17 | self.geojson_validator = LineEditColorValidator( 18 | self.txtUrl, "http[s]?://.+", error_tooltip="http{s}://any_text" 19 | ) 20 | 21 | def feel_form(self, ds_info): 22 | self.ds_info = ds_info 23 | self.txtUrl.setText(ds_info.geojson_url) 24 | 25 | def feel_ds_info(self, ds_info): 26 | ds_info.geojson_url = self.txtUrl.text() 27 | 28 | def validate(self, ds_info): 29 | if not ds_info.geojson_url: 30 | QMessageBox.critical( 31 | self, 32 | self.tr("Error on save data source"), 33 | self.tr("Please, enter GeoJSON url"), 34 | ) 35 | return False 36 | 37 | if not self.geojson_validator.is_valid(): 38 | QMessageBox.critical( 39 | self, 40 | self.tr("Error on save data source"), 41 | self.tr("Please, enter correct value for GeoJSON url"), 42 | ) 43 | return False 44 | 45 | return True 46 | -------------------------------------------------------------------------------- /src/quick_map_services/supported_drivers.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | /*************************************************************************** 4 | QuickMapServices 5 | A QGIS plugin 6 | Collection of internet map services 7 | ------------------- 8 | begin : 2014-11-21 9 | git sha : $Format:%H$ 10 | copyright : (C) 2014 by NextGIS 11 | email : info@nextgis.com 12 | ***************************************************************************/ 13 | 14 | /*************************************************************************** 15 | * * 16 | * This program is free software; you can redistribute it and/or modify * 17 | * it under the terms of the GNU General Public License as published by * 18 | * the Free Software Foundation; either version 2 of the License, or * 19 | * (at your option) any later version. * 20 | * * 21 | ***************************************************************************/ 22 | """ 23 | 24 | 25 | class KNOWN_DRIVERS: 26 | WMS = "WMS" 27 | TMS = "TMS" 28 | GDAL = "GDAL" 29 | WFS = "WFS" 30 | GEOJSON = "GeoJSON" 31 | 32 | ALL_DRIVERS = [ 33 | WMS, 34 | TMS, 35 | GDAL, 36 | WFS, 37 | GEOJSON, 38 | ] 39 | 40 | # 'TiledWMS', 41 | # 'VirtualEarth', 42 | # 'WorldWind', 43 | # 'AGS' 44 | -------------------------------------------------------------------------------- /src/quick_map_services/notifier/notifier_interface.py: -------------------------------------------------------------------------------- 1 | from abc import abstractmethod 2 | 3 | from qgis.core import Qgis 4 | from qgis.PyQt.QtCore import QObject 5 | 6 | from quick_map_services.shared.qobject_metaclass import QObjectMetaClass 7 | 8 | 9 | class NotifierInterface(QObject, metaclass=QObjectMetaClass): 10 | """Interface for displaying messages to the user. 11 | 12 | This interface defines methods for presenting messages, as well as 13 | dismissing individual or all messages. 14 | """ 15 | 16 | @abstractmethod 17 | def display_message( 18 | self, 19 | message: str, 20 | *, 21 | level: Qgis.MessageLevel = Qgis.MessageLevel.Info, 22 | **kwargs, # noqa: ANN003 23 | ) -> str: 24 | """Display a message to the user. 25 | 26 | :param message: The message to display. 27 | :param level: The message level as Qgis.MessageLevel. 28 | :return: An identifier for the displayed message. 29 | """ 30 | ... 31 | 32 | @abstractmethod 33 | def display_exception(self, error: Exception) -> str: 34 | """Display an exception as an error message to the user. 35 | 36 | :param error: The exception to display. 37 | :return: An identifier for the displayed message. 38 | """ 39 | ... 40 | 41 | @abstractmethod 42 | def dismiss_message(self, message_id: str) -> None: 43 | """Dismiss a specific message by its identifier. 44 | 45 | :param message_id: The identifier of the message to dismiss. 46 | """ 47 | ... 48 | 49 | @abstractmethod 50 | def dismiss_all(self) -> None: 51 | """Dismiss all currently displayed messages.""" 52 | ... 53 | -------------------------------------------------------------------------------- /src/quick_map_services/gui/editor_widget_wms.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | Form 4 | 5 | 6 | 7 | 0 8 | 0 9 | 395 10 | 169 11 | 12 | 13 | 14 | Form 15 | 16 | 17 | 18 | QFormLayout::ExpandingFieldsGrow 19 | 20 | 21 | 22 | 23 | URL 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | Params 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | Layers 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | Turn over 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | -------------------------------------------------------------------------------- /src/quick_map_services/gdal_utils.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | /*************************************************************************** 4 | QuickMapServices 5 | A QGIS plugin 6 | Collection of internet map services 7 | ------------------- 8 | begin : 2014-11-21 9 | git sha : $Format:%H$ 10 | copyright : (C) 2014 by NextGIS 11 | email : info@nextgis.com 12 | ***************************************************************************/ 13 | 14 | /*************************************************************************** 15 | * * 16 | * This program is free software; you can redistribute it and/or modify * 17 | * it under the terms of the GNU General Public License as published by * 18 | * the Free Software Foundation; either version 2 of the License, or * 19 | * (at your option) any later version. * 20 | * * 21 | ***************************************************************************/ 22 | """ 23 | 24 | from .supported_drivers import KNOWN_DRIVERS 25 | 26 | try: 27 | from osgeo import gdal 28 | except ImportError: 29 | import gdal 30 | 31 | 32 | class GdalUtils: 33 | @classmethod 34 | def get_supported_drivers(cls): 35 | formats_list = [] 36 | cnt = gdal.GetDriverCount() 37 | for i in range(cnt): 38 | driver = gdal.GetDriver(i) 39 | driver_name = driver.ShortName 40 | if not driver_name in formats_list: 41 | formats_list.append(driver_name) 42 | if "WMS" in formats_list: 43 | return KNOWN_DRIVERS # all drivers if wms supported. hack. need to remake 44 | -------------------------------------------------------------------------------- /src/quick_map_services/group_info.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | /*************************************************************************** 4 | QuickMapServices 5 | A QGIS plugin 6 | Collection of internet map services 7 | ------------------- 8 | begin : 2014-11-21 9 | git sha : $Format:%H$ 10 | copyright : (C) 2014 by NextGIS 11 | email : info@nextgis.com 12 | ***************************************************************************/ 13 | 14 | /*************************************************************************** 15 | * * 16 | * This program is free software; you can redistribute it and/or modify * 17 | * it under the terms of the GNU General Public License as published by * 18 | * the Free Software Foundation; either version 2 of the License, or * 19 | * (at your option) any later version. * 20 | * * 21 | ***************************************************************************/ 22 | """ 23 | 24 | 25 | class GroupCategory: 26 | BASE = "base" 27 | CONTRIB = "contributed" 28 | USER = "user" 29 | 30 | all = [BASE, CONTRIB, USER] 31 | 32 | 33 | class GroupInfo: 34 | def __init__( 35 | self, 36 | group_id=None, 37 | alias=None, 38 | icon=None, 39 | file_path=None, 40 | menu=None, 41 | category=None, 42 | ): 43 | # general 44 | self.id = group_id 45 | # ui 46 | self.alias = alias 47 | self.icon = icon 48 | 49 | # internal 50 | self.file_path = file_path 51 | self.menu = menu 52 | 53 | self.category = category 54 | -------------------------------------------------------------------------------- /src/quick_map_services/singleton.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | /*************************************************************************** 4 | QuickMapServices 5 | A QGIS plugin 6 | Collection of internet map services 7 | ------------------- 8 | begin : 2014-11-21 9 | git sha : $Format:%H$ 10 | copyright : (C) 2014 by NextGIS 11 | email : info@nextgis.com 12 | ***************************************************************************/ 13 | 14 | /*************************************************************************** 15 | * * 16 | * This program is free software; you can redistribute it and/or modify * 17 | * it under the terms of the GNU General Public License as published by * 18 | * the Free Software Foundation; either version 2 of the License, or * 19 | * (at your option) any later version. * 20 | * * 21 | ***************************************************************************/ 22 | """ 23 | 24 | from qgis.PyQt.QtCore import QObject as QParentClass 25 | 26 | 27 | def singleton(class_): 28 | instances = {} 29 | 30 | def getinstance(*args, **kwargs): 31 | if class_ not in instances: 32 | instances[class_] = class_(*args, **kwargs) 33 | return instances[class_] 34 | 35 | return getinstance 36 | 37 | 38 | class QSingleton(QParentClass): 39 | def __init__(cls, name, bases, dict): 40 | super(QSingleton, cls).__init__(cls, bases, dict) 41 | cls._instance = None 42 | 43 | def __call__(cls, *args, **kwargs): 44 | if cls._instance is None: 45 | cls._instance = super(QSingleton, cls).__call__(*args, **kwargs) 46 | return cls._instance 47 | -------------------------------------------------------------------------------- /src/quick_map_services/gui/editor_widget_wms.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from qgis.PyQt import uic 4 | from qgis.PyQt.QtWidgets import QMessageBox, QWidget 5 | 6 | from .line_edit_color_validator import LineEditColorValidator 7 | 8 | FORM_CLASS, _ = uic.loadUiType( 9 | os.path.join(os.path.dirname(__file__), "editor_widget_wms.ui") 10 | ) 11 | 12 | 13 | class EditorWidgetWms(QWidget, FORM_CLASS): 14 | def __init__(self, parent=None): 15 | super(EditorWidgetWms, self).__init__(parent) 16 | self.setupUi(self) 17 | self.wms_validator = LineEditColorValidator( 18 | self.txtUrl, "http[s]?://.+", error_tooltip="http{s}://any_text" 19 | ) 20 | 21 | def feel_form(self, ds_info): 22 | self.ds_info = ds_info 23 | self.txtUrl.setText(ds_info.wms_url) 24 | self.txtParams.setText( 25 | ds_info.wms_params + "&" + ds_info.wms_url_params 26 | ) 27 | self.txtLayers.setText(ds_info.wms_layers) 28 | self.chkTurnOver.setChecked( 29 | ds_info.wms_turn_over if ds_info.wms_turn_over else False 30 | ) 31 | 32 | def feel_ds_info(self, ds_info): 33 | ds_info.wms_url = self.txtUrl.text() 34 | ds_info.wms_params = self.txtParams.text() 35 | ds_info.wms_layers = self.txtLayers.text() 36 | ds_info.wms_turn_over = self.chkTurnOver.isChecked() 37 | 38 | def validate(self, ds_info): 39 | if not ds_info.wms_url: 40 | QMessageBox.critical( 41 | self, 42 | self.tr("Error on save data source"), 43 | self.tr("Please, enter WMS url"), 44 | ) 45 | return False 46 | 47 | if not self.wms_validator.is_valid(): 48 | QMessageBox.critical( 49 | self, 50 | self.tr("Error on save data source"), 51 | self.tr("Please, enter correct value for WMS url"), 52 | ) 53 | return False 54 | 55 | return True 56 | -------------------------------------------------------------------------------- /src/quick_map_services/gui/editor_widget_wfs.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from qgis.PyQt import uic 4 | from qgis.PyQt.QtWidgets import QMessageBox, QWidget 5 | 6 | from .line_edit_color_validator import LineEditColorValidator 7 | 8 | FORM_CLASS, _ = uic.loadUiType( 9 | os.path.join(os.path.dirname(__file__), "editor_widget_wfs.ui") 10 | ) 11 | 12 | 13 | class EditorWidgetWfs(QWidget, FORM_CLASS): 14 | def __init__(self, parent=None): 15 | super(EditorWidgetWfs, self).__init__(parent) 16 | self.setupUi(self) 17 | self.wfs_validator = LineEditColorValidator( 18 | self.txtUrl, "http[s]?://.+", error_tooltip="http{s}://any_text" 19 | ) 20 | # self.txtUrl.textChanged.connect(self.set_layers_names) 21 | 22 | def feel_form(self, ds_info): 23 | self.ds_info = ds_info 24 | self.txtUrl.setText(ds_info.wfs_url) 25 | self.txtParams.setText(ds_info.wfs_params) 26 | self.txtLayers.setText(",".join(ds_info.wfs_layers)) 27 | self.chkTurnOver.setChecked( 28 | ds_info.wfs_turn_over if ds_info.wfs_turn_over else False 29 | ) 30 | 31 | def feel_ds_info(self, ds_info): 32 | ds_info.wfs_url = self.txtUrl.text() 33 | ds_info.wfs_params = self.txtParams.text() 34 | ds_info.wfs_layers = self.txtLayers.text().split() 35 | ds_info.wfs_turn_over = self.chkTurnOver.isChecked() 36 | 37 | def validate(self, ds_info): 38 | if not ds_info.wfs_url: 39 | QMessageBox.critical( 40 | self, 41 | self.tr("Error on save data source"), 42 | self.tr("Please, enter WFS url"), 43 | ) 44 | return False 45 | 46 | if not self.wfs_validator.is_valid(): 47 | QMessageBox.critical( 48 | self, 49 | self.tr("Error on save data source"), 50 | self.tr("Please, enter correct value for WMS url"), 51 | ) 52 | return False 53 | 54 | return True 55 | -------------------------------------------------------------------------------- /src/quick_map_services/custom_translator.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | /*************************************************************************** 4 | QuickMapServices 5 | A QGIS plugin 6 | Collection of internet map services 7 | ------------------- 8 | begin : 2014-11-21 9 | git sha : $Format:%H$ 10 | copyright : (C) 2014 by NextGIS 11 | email : info@nextgis.com 12 | ***************************************************************************/ 13 | 14 | /*************************************************************************** 15 | * * 16 | * This program is free software; you can redistribute it and/or modify * 17 | * it under the terms of the GNU General Public License as published by * 18 | * the Free Software Foundation; either version 2 of the License, or * 19 | * (at your option) any later version. * 20 | * * 21 | ***************************************************************************/ 22 | """ 23 | 24 | from qgis.PyQt.QtCore import QCoreApplication, QTranslator 25 | 26 | from .singleton import singleton 27 | 28 | 29 | @singleton 30 | class CustomTranslator: 31 | def __init__(self): 32 | self.__translates = {} 33 | 34 | def append(self, text, translation): 35 | if text and translation: 36 | self.__translates[text] = translation 37 | 38 | def clear_translations(self): 39 | self.__translates.clear() 40 | 41 | def translate(self, context, text): 42 | try: 43 | if ( 44 | isinstance(text, str) or isinstance(text, unicode) 45 | ) and text in self.__translates: 46 | return self.__translates[text] 47 | finally: 48 | return QCoreApplication.translate(context, text) 49 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/01_bug_report.yaml: -------------------------------------------------------------------------------- 1 | name: Bug report 2 | description: Create a bug report to help us improve the QuickMapServices plugin. 3 | labels: 4 | - 'bug' 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: | 9 | Thank you for taking the time to fill out an issue, this template is meant for any issues related to the QuickMapServices plugin. 10 | If you require help with the NextGIS software, we ask that you join [our Discord](https://discord.gg/V3G9snXGy5) 11 | - type: textarea 12 | id: description 13 | attributes: 14 | label: Describe the bug 15 | description: "A clear and concise description of what the bug is." 16 | validations: 17 | required: true 18 | - type: textarea 19 | id: steps 20 | attributes: 21 | label: Steps to reproduce the issue 22 | description: | 23 | Steps to reproduce the behavior. If possible, include a link to the service. Screenshots or screen recordings are highly appreciated (you can drag & drop them into the text box). 24 | 1. Go to '...' 25 | 2. Click on '...' 26 | 3. Scroll down to '...' 27 | 4. Wait for the moon phase 28 | 5. See error 29 | validations: 30 | required: true 31 | - type: textarea 32 | id: logs 33 | attributes: 34 | label: Logs 35 | description: Paste any relevant logs here (e.g. from the Log Messages panel in QGIS) 36 | render: Text 37 | # Avoid rendering as Markdown here. 38 | - type: textarea 39 | id: about-info 40 | attributes: 41 | label: Versions 42 | description: | 43 | In the QGIS Help menu -> About, click in the table, Ctrl+A and then Ctrl+C. Finally paste here. 44 | Do not make a screenshot. 45 | validations: 46 | required: true 47 | - type: checkboxes 48 | id: plugin-version 49 | attributes: 50 | label: Supported plugin version 51 | options: 52 | - label: I'm running a supported QuickMapServices version according to [the versions page](https://plugins.qgis.org/plugins/quick_map_services/#plugin-versions). 53 | - type: textarea 54 | id: anything-else 55 | attributes: 56 | label: Anything else? 57 | description: Let us know if you have anything else to share. -------------------------------------------------------------------------------- /src/quick_map_services/gui/editor_widget_wfs.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | Form 4 | 5 | 6 | 7 | 0 8 | 0 9 | 395 10 | 169 11 | 12 | 13 | 14 | Form 15 | 16 | 17 | 18 | QFormLayout::ExpandingFieldsGrow 19 | 20 | 21 | 22 | 23 | URL 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | true 34 | 35 | 36 | Params 37 | 38 | 39 | 40 | 41 | 42 | 43 | true 44 | 45 | 46 | 47 | 48 | 49 | 50 | true 51 | 52 | 53 | Layers 54 | 55 | 56 | 57 | 58 | 59 | 60 | true 61 | 62 | 63 | 64 | 65 | 66 | 67 | true 68 | 69 | 70 | Turn over 71 | 72 | 73 | 74 | 75 | 76 | 77 | true 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "quick_map_services" 3 | version = "1.1.0" 4 | readme = "README.md" 5 | license = { file = "LICENSE" } 6 | 7 | 8 | [tool.qgspb.package-data] 9 | "quick_map_services.data_sources" = [ 10 | "osm_mapnik/*", 11 | ] 12 | "quick_map_services.groups" = ["nasa/*", "osm/*"] 13 | "quick_map_services.icons" = ["*.svg", "*.png"] 14 | 15 | [tool.qgspb.forms] 16 | ui-files = [ 17 | "src/quick_map_services/gui/*.ui", 18 | "src/quick_map_services/scales.xml", 19 | "src/quick_map_services/*.ui", 20 | ] 21 | compile = false 22 | 23 | [tool.qgspb.translations] 24 | ts-files = ["src/quick_map_services/i18n/*.ts"] 25 | no-obsolete = true 26 | 27 | 28 | [project.optional-dependencies] 29 | dev = ["ruff", "pre-commit"] 30 | 31 | [tool.pyright] 32 | include = ["src"] 33 | pythonVersion = "3.7" 34 | 35 | reportOptionalCall = false 36 | reportOptionalMemberAccess = false 37 | 38 | [tool.ruff] 39 | line-length = 79 40 | target-version = "py37" 41 | 42 | [tool.ruff.lint] 43 | select = [ 44 | # "A", # flake8-builtins 45 | # "ARG", # flake8-unused-arguments 46 | # "B", # flake8-bugbear 47 | # "C90", # mccabe complexity 48 | # "COM", # flake8-commas 49 | # "E", # pycodestyle errors 50 | # "F", # pyflakes 51 | # "FBT", # flake8-boolean-trap 52 | # "FLY", # flynt 53 | "I", # isort 54 | # "ISC", # flake8-implicit-str-concat 55 | # "LOG", # flake8-logging 56 | # "N", # pep8-naming 57 | # "PERF", # Perflint 58 | # "PGH", # pygrep-hooks 59 | # "PIE", # flake8-pie 60 | # "PL", # pylint 61 | # "PTH", # flake8-use-pathlib 62 | # "PYI", # flake8-pyi 63 | # "Q", # flake8-quotes 64 | # "RET", # flake8-return 65 | # "RSE", # flake8-raise 66 | # "RUF", 67 | # "SIM", # flake8-simplify 68 | # "SLF", # flake8-self 69 | # "T10", # flake8-debugger 70 | # "T20", # flake8-print 71 | # "TCH", # flake8-type-checking 72 | # "TD", # flake8-todos 73 | # "TID", # flake8-tidy-imports 74 | # "TRY", # tryceratops 75 | # "UP", # pyupgrade 76 | # "W", # pycodesytle warnings 77 | # "ANN", # flake8-annotations 78 | # "CPY", # flake8-copyright 79 | # "D", # pydocstyle 80 | # "FIX", # flake8-fixme 81 | ] 82 | ignore = ["ANN101", "ANN102", "TD003", "FBT003", "ISC001", "COM812", "E501"] 83 | 84 | [tool.ruff.lint.per-file-ignores] 85 | "__init__.py" = ["F401"] 86 | 87 | [tool.ruff.lint.pep8-naming] 88 | extend-ignore-names = [ 89 | "setLevel", 90 | "classFactory", 91 | "initGui", 92 | "sizeHint", 93 | "createWidget", 94 | "*Event", 95 | ] 96 | -------------------------------------------------------------------------------- /src/quick_map_services/gui/user_services_box.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | UserServicesBox 4 | 5 | 6 | 7 | 0 8 | 0 9 | 400 10 | 246 11 | 12 | 13 | 14 | UserServices 15 | 16 | 17 | User Services 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | Create service from existing 29 | 30 | 31 | ... 32 | 33 | 34 | 35 | 36 | 37 | 38 | Create service 39 | 40 | 41 | ... 42 | 43 | 44 | 45 | 46 | 47 | 48 | false 49 | 50 | 51 | Edit service 52 | 53 | 54 | ... 55 | 56 | 57 | 58 | 59 | 60 | 61 | false 62 | 63 | 64 | Delete service 65 | 66 | 67 | ... 68 | 69 | 70 | 71 | 72 | 73 | 74 | Qt::Vertical 75 | 76 | 77 | 78 | 20 79 | 40 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | -------------------------------------------------------------------------------- /src/quick_map_services/quick_map_services_plugin_stub.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from typing import TYPE_CHECKING, Optional 3 | 4 | from osgeo import gdal 5 | from qgis.core import Qgis 6 | from qgis.gui import QgisInterface 7 | from qgis.PyQt.QtCore import QT_VERSION_STR, QObject, QSysInfo 8 | from qgis.utils import iface 9 | 10 | from quick_map_services.core import utils 11 | from quick_map_services.core.constants import PLUGIN_NAME 12 | from quick_map_services.core.logging import logger 13 | from quick_map_services.notifier.message_bar_notifier import MessageBarNotifier 14 | from quick_map_services.quick_map_services_interface import ( 15 | QuickMapServicesInterface, 16 | ) 17 | 18 | if TYPE_CHECKING: 19 | from quick_map_services.notifier.notifier_interface import ( 20 | NotifierInterface, 21 | ) 22 | 23 | assert isinstance(iface, QgisInterface) 24 | 25 | 26 | class QuickMapServicesPluginStub(QuickMapServicesInterface): 27 | """Stub implementation of plugin interface used to notify the user when the plugin failed to start.""" 28 | 29 | def __init__(self, parent: Optional[QObject] = None) -> None: 30 | """Initialize the plugin stub.""" 31 | super().__init__(parent) 32 | metadata_file = self.path / "metadata.txt" 33 | 34 | logger.debug("✓ Plugin stub created") 35 | logger.debug(f"ⓘ OS: {QSysInfo().prettyProductName()}") 36 | logger.debug(f"ⓘ Qt version: {QT_VERSION_STR}") 37 | logger.debug(f"ⓘ QGIS version: {Qgis.version()}") 38 | logger.debug(f"ⓘ Python version: {sys.version}") 39 | logger.debug(f"ⓘ GDAL version: {gdal.__version__}") 40 | logger.debug(f"ⓘ Plugin version: {self.version}") 41 | logger.debug( 42 | f"ⓘ Plugin path: {self.path}" 43 | + ( 44 | f" -> {metadata_file.resolve().parent}" 45 | if metadata_file.is_symlink() 46 | else "" 47 | ) 48 | ) 49 | 50 | self.__notifier = None 51 | 52 | @property 53 | def notifier(self) -> "NotifierInterface": 54 | """Return the notifier for displaying messages to the user. 55 | 56 | :returns: Notifier interface instance. 57 | :rtype: NotifierInterface 58 | """ 59 | assert self.__notifier is not None, "Notifier is not initialized" 60 | return self.__notifier 61 | 62 | def _load(self) -> None: 63 | """Load the plugin resources and initialize components.""" 64 | self._add_translator( 65 | self.path / "i18n" / f"{PLUGIN_NAME}_{utils.locale()}.qm", 66 | ) 67 | self.__notifier = MessageBarNotifier(self) 68 | 69 | def _unload(self) -> None: 70 | """Unload the plugin resources and clean up components.""" 71 | self.__notifier.deleteLater() 72 | self.__notifier = None 73 | -------------------------------------------------------------------------------- /src/quick_map_services/gui/user_groups_box.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | UserGroupsBox 4 | 5 | 6 | 7 | 0 8 | 0 9 | 475 10 | 252 11 | 12 | 13 | 14 | UserGroups 15 | 16 | 17 | User Groups 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | Create group from existing 29 | 30 | 31 | ... 32 | 33 | 34 | 35 | 36 | 37 | 38 | Create group 39 | 40 | 41 | ... 42 | 43 | 44 | 45 | 16 46 | 16 47 | 48 | 49 | 50 | QToolButton::DelayedPopup 51 | 52 | 53 | Qt::ToolButtonIconOnly 54 | 55 | 56 | 57 | 58 | 59 | 60 | false 61 | 62 | 63 | Edit group 64 | 65 | 66 | ... 67 | 68 | 69 | 70 | 71 | 72 | 73 | false 74 | 75 | 76 | Delete group 77 | 78 | 79 | ... 80 | 81 | 82 | 83 | 84 | 85 | 86 | Qt::Vertical 87 | 88 | 89 | 90 | 20 91 | 40 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | -------------------------------------------------------------------------------- /src/quick_map_services/icons/nextgis_logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 7 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 26 | 28 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /src/quick_map_services/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | /*************************************************************************** 4 | QuickMapServices 5 | A QGIS plugin 6 | Collection of internet map services 7 | ------------------- 8 | begin : 2014-11-21 9 | copyright : (C) 2014 by NextGIS 10 | email : info@nextgis.com 11 | git sha : $Format:%H$ 12 | ***************************************************************************/ 13 | 14 | /*************************************************************************** 15 | * * 16 | * This program is free software; you can redistribute it and/or modify * 17 | * it under the terms of the GNU General Public License as published by * 18 | * the Free Software Foundation; either version 2 of the License, or * 19 | * (at your option) any later version. * 20 | * * 21 | ***************************************************************************/ 22 | This script initializes the plugin, making it known to QGIS. 23 | """ 24 | 25 | from typing import TYPE_CHECKING 26 | 27 | from qgis.core import QgsRuntimeProfiler 28 | 29 | from quick_map_services.core.exceptions import QmsReloadAfterUpdateWarning 30 | from quick_map_services.core.settings import QmsSettings 31 | from quick_map_services.quick_map_services_interface import ( 32 | QuickMapServicesInterface, 33 | ) 34 | 35 | if TYPE_CHECKING: 36 | from qgis.gui import QgisInterface 37 | 38 | 39 | def classFactory(_iface: "QgisInterface") -> QuickMapServicesInterface: 40 | """Create and return an instance of the QuickMapServices plugin. 41 | 42 | :param _iface: QGIS interface instance passed by QGIS at plugin load. 43 | :type _iface: QgisInterface 44 | :returns: An instance of QuickMapServicesInterface (plugin or stub). 45 | :rtype: QuickMapServicesInterface 46 | """ 47 | settings = QmsSettings() 48 | 49 | try: 50 | with QgsRuntimeProfiler.profile("Import plugin"): 51 | from quick_map_services.quick_map_services import QuickMapServices 52 | 53 | plugin = QuickMapServices() 54 | settings.did_last_launch_fail = False 55 | 56 | except Exception as error: 57 | import copy 58 | 59 | from qgis.PyQt.QtCore import QTimer 60 | 61 | from quick_map_services.quick_map_services_plugin_stub import ( 62 | QuickMapServicesPluginStub, 63 | ) 64 | 65 | error_copy = copy.deepcopy(error) 66 | exception = error_copy 67 | 68 | if not settings.did_last_launch_fail: 69 | # Sometimes after an update that changes the plugin structure, 70 | # the plugin may fail to load. Restarting QGIS helps. 71 | exception = QmsReloadAfterUpdateWarning() 72 | exception.__cause__ = error_copy 73 | 74 | settings.did_last_launch_fail = True 75 | 76 | plugin = QuickMapServicesPluginStub() 77 | 78 | def display_exception() -> None: 79 | plugin.notifier.display_exception(exception) 80 | 81 | QTimer.singleShot(0, display_exception) 82 | 83 | return plugin 84 | -------------------------------------------------------------------------------- /src/quick_map_services/core/compat.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING 2 | 3 | from qgis.core import ( 4 | Qgis, 5 | QgsFeatureRequest, 6 | QgsMapLayerProxyModel, 7 | QgsMapLayerType, 8 | QgsWkbTypes, 9 | ) 10 | from qgis.PyQt.QtCore import QT_VERSION_STR, QMetaType, QVariant 11 | 12 | QGIS_3_30 = 33000 13 | QGIS_3_32 = 33200 14 | QGIS_3_34 = 33400 15 | QGIS_3_36 = 33600 16 | QGIS_3_38 = 33800 17 | QGIS_3_40 = 34000 18 | QGIS_3_42 = 34200 19 | QGIS_3_42_2 = 34202 20 | 21 | QT_MAJOR_VERSION = int(QT_VERSION_STR.split(".")[0]) 22 | 23 | if QT_MAJOR_VERSION < 6: 24 | from qgis.PyQt.QtCore import QIODevice 25 | 26 | OpenModeFlag = QIODevice.OpenModeFlag.WriteOnly 27 | else: 28 | from qgis.PyQt.QtCore import QIODeviceBase 29 | 30 | OpenModeFlag = QIODeviceBase.OpenModeFlag.WriteOnly 31 | 32 | if Qgis.versionInt() >= QGIS_3_30 or TYPE_CHECKING: 33 | WkbType = Qgis.WkbType # type: ignore 34 | 35 | GeometryType = Qgis.GeometryType # type: ignore 36 | 37 | LayerType = Qgis.LayerType # type: ignore 38 | 39 | else: 40 | WkbType = QgsWkbTypes.Type # type: ignore 41 | 42 | GeometryType = QgsWkbTypes.GeometryType # type: ignore 43 | GeometryType.Point = GeometryType.PointGeometry # type: ignore 44 | GeometryType.Point.is_monkey_patched = True 45 | GeometryType.Line = GeometryType.LineGeometry # type: ignore 46 | GeometryType.Line.is_monkey_patched = True 47 | GeometryType.Polygon = GeometryType.PolygonGeometry # type: ignore 48 | GeometryType.Polygon.is_monkey_patched = True 49 | GeometryType.Unknown = GeometryType.UnknownGeometry # type: ignore 50 | GeometryType.Unknown.is_monkey_patched = True 51 | GeometryType.Null = GeometryType.NullGeometry # type: ignore 52 | GeometryType.Null.is_monkey_patched = True 53 | 54 | LayerType = QgsMapLayerType 55 | LayerType.Vector = QgsMapLayerType.VectorLayer # type: ignore 56 | LayerType.Vector.is_monkey_patched = True 57 | LayerType.Raster = QgsMapLayerType.RasterLayer # type: ignore 58 | LayerType.Raster.is_monkey_patched = True 59 | LayerType.Plugin = QgsMapLayerType.PluginLayer # type: ignore 60 | LayerType.Plugin.is_monkey_patched = True 61 | LayerType.Mesh = QgsMapLayerType.MeshLayer # type: ignore 62 | LayerType.Mesh.is_monkey_patched = True 63 | LayerType.VectorTile = QgsMapLayerType.VectorTileLayer # type: ignore 64 | LayerType.VectorTile.is_monkey_patched = True 65 | LayerType.Annotation = QgsMapLayerType.AnnotationLayer # type: ignore 66 | LayerType.Annotation.is_monkey_patched = True 67 | LayerType.PointCloud = QgsMapLayerType.PointCloudLayer # type: ignore 68 | LayerType.PointCloud.is_monkey_patched = True 69 | 70 | if Qgis.versionInt() >= QGIS_3_34 or TYPE_CHECKING: 71 | LayerFilter = Qgis.LayerFilter 72 | LayerFilters = Qgis.LayerFilters 73 | 74 | else: 75 | LayerFilter = QgsMapLayerProxyModel.Filter 76 | LayerFilters = QgsMapLayerProxyModel.Filters 77 | 78 | if Qgis.versionInt() >= QGIS_3_36 or TYPE_CHECKING: 79 | FeatureRequestFlag = Qgis.FeatureRequestFlag 80 | FeatureRequestFlags = Qgis.FeatureRequestFlags 81 | 82 | else: 83 | FeatureRequestFlag = QgsFeatureRequest.Flag 84 | FeatureRequestFlags = QgsFeatureRequest.Flags 85 | 86 | 87 | if Qgis.versionInt() >= QGIS_3_38 or TYPE_CHECKING: 88 | FieldType = QMetaType.Type 89 | else: 90 | FieldType = QVariant.Type 91 | FieldType.QString = QVariant.Type.String 92 | FieldType.QString.is_monkey_patched = True 93 | FieldType.LongLong = QVariant.Type.LongLong 94 | FieldType.LongLong.is_monkey_patched = True 95 | 96 | try: 97 | from packaging import version 98 | 99 | parse_version = version.parse 100 | 101 | except Exception: 102 | import pkg_resources 103 | 104 | parse_version = pkg_resources.parse_version # type: ignore 105 | -------------------------------------------------------------------------------- /src/quick_map_services/data_source_info.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | /*************************************************************************** 4 | QuickMapServices 5 | A QGIS plugin 6 | Collection of internet map services 7 | ------------------- 8 | begin : 2014-11-21 9 | git sha : $Format:%H$ 10 | copyright : (C) 2014 by NextGIS 11 | email : info@nextgis.com 12 | ***************************************************************************/ 13 | 14 | /*************************************************************************** 15 | * * 16 | * This program is free software; you can redistribute it and/or modify * 17 | * it under the terms of the GNU General Public License as published by * 18 | * the Free Software Foundation; either version 2 of the License, or * 19 | * (at your option) any later version. * 20 | * * 21 | ***************************************************************************/ 22 | """ 23 | 24 | import itertools 25 | import re 26 | 27 | 28 | class DataSourceCategory: 29 | BASE = "base" 30 | CONTRIB = "contributed" 31 | USER = "user" 32 | 33 | all = [BASE, CONTRIB, USER] 34 | 35 | 36 | class DataSourceInfo: 37 | def __init__(self): 38 | self.id = None 39 | self.type = None 40 | 41 | self.group = None 42 | self.alias = None 43 | self.icon = None 44 | 45 | self.lic_name = None 46 | self.lic_link = None 47 | self.copyright_text = None 48 | self.copyright_link = None 49 | self.terms_of_use = None 50 | 51 | self.__tms_url = None 52 | self.__alt_tms_url = None 53 | self.tms_zmin = None 54 | self.tms_zmax = None 55 | self.tms_y_origin_top = None 56 | self.tms_epsg_crs_id = None 57 | self.tms_postgis_crs_id = None 58 | self.tms_custom_proj = None 59 | self.tms_tile_ranges = None 60 | self.tms_tsize1 = None 61 | self.tms_origin_x = None 62 | self.tms_origin_y = None 63 | 64 | self.wms_url = None 65 | self.wms_params = None 66 | self.wms_url_params = None 67 | self.wms_layers = None 68 | self.wms_turn_over = None 69 | 70 | self.gdal_source_file = None 71 | 72 | self.wfs_url = None 73 | self.wfs_layers = [] 74 | self.wfs_params = None 75 | self.wfs_epsg = None 76 | self.wfs_turn_over = None 77 | 78 | self.geojson_url = None 79 | 80 | # internal 81 | self.file_path = None 82 | self.icon_path = None 83 | self.action = None 84 | self.category = None 85 | 86 | def _parse_tms_url(self, url): 87 | if url is None: 88 | return [] 89 | 90 | url = url.replace( 91 | "%", "%%" 92 | ) # escaping percent symbols before string formatting below 93 | 94 | switch_re = r"{switch:[^\}]*}" 95 | switches = re.findall(switch_re, url) 96 | 97 | url_pattern = url 98 | for switch in switches: 99 | url_pattern = url_pattern.replace(switch, "%s", 1) 100 | 101 | switch_variants = [] 102 | for switch in switches: 103 | switch_variants.append(switch[8:-1].split(",")) 104 | 105 | urls = [] 106 | for variants in list(itertools.product(*switch_variants)): 107 | urls.append(url_pattern % variants) 108 | return urls 109 | 110 | @property 111 | def tms_url(self): 112 | return self.__tms_url 113 | 114 | @tms_url.setter 115 | def tms_url(self, url): 116 | self.__tms_url = url 117 | self.__alt_tms_url = self._parse_tms_url(self.__tms_url) 118 | 119 | @property 120 | def alt_tms_urls(self): 121 | return self.__alt_tms_url 122 | -------------------------------------------------------------------------------- /src/quick_map_services/gui/editor_widget_tms.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | Form 4 | 5 | 6 | 7 | 0 8 | 0 9 | 411 10 | 205 11 | 12 | 13 | 14 | Form 15 | 16 | 17 | 18 | 19 | 20 | URL 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | Z min 31 | 32 | 33 | 34 | 35 | 36 | 37 | 25 38 | 39 | 40 | 41 | 42 | 43 | 44 | Z max 45 | 46 | 47 | 48 | 49 | 50 | 51 | 25 52 | 53 | 54 | 19 55 | 56 | 57 | 58 | 59 | 60 | 61 | Y Origin top 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | CRS 72 | 73 | 74 | 75 | 76 | 77 | 78 | QFrame::StyledPanel 79 | 80 | 81 | QFrame::Raised 82 | 83 | 84 | 85 | 86 | 87 | CRS ID 88 | 89 | 90 | true 91 | 92 | 93 | 94 | 95 | 96 | 97 | PostGIS CRS ID 98 | 99 | 100 | 101 | 102 | 103 | 104 | Custom proj 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | -------------------------------------------------------------------------------- /src/quick_map_services/data_sources_list.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | /*************************************************************************** 4 | QuickMapServices 5 | A QGIS plugin 6 | Collection of internet map services 7 | ------------------- 8 | begin : 2014-11-21 9 | git sha : $Format:%H$ 10 | copyright : (C) 2014 by NextGIS 11 | email : info@nextgis.com 12 | ***************************************************************************/ 13 | 14 | /*************************************************************************** 15 | * * 16 | * This program is free software; you can redistribute it and/or modify * 17 | * it under the terms of the GNU General Public License as published by * 18 | * the Free Software Foundation; either version 2 of the License, or * 19 | * (at your option) any later version. * 20 | * * 21 | ***************************************************************************/ 22 | """ 23 | 24 | import os 25 | 26 | from qgis.PyQt.QtGui import QIcon 27 | from qgis.PyQt.QtWidgets import QAction 28 | 29 | from quick_map_services.core.logging import logger 30 | 31 | from . import extra_sources 32 | from .custom_translator import CustomTranslator 33 | from .data_source_info import DataSourceCategory 34 | from .data_source_serializer import DataSourceSerializer 35 | 36 | CURR_PATH = os.path.dirname(__file__) 37 | 38 | INTERNAL_DS_PATHS = [ 39 | os.path.join(CURR_PATH, extra_sources.DATA_SOURCES_DIR_NAME), 40 | ] 41 | CONTRIBUTE_DS_PATHS = [ 42 | os.path.join( 43 | extra_sources.CONTRIBUTE_DIR_PATH, extra_sources.DATA_SOURCES_DIR_NAME 44 | ), 45 | ] 46 | USER_DS_PATHS = [ 47 | os.path.join( 48 | extra_sources.USER_DIR_PATH, extra_sources.DATA_SOURCES_DIR_NAME 49 | ), 50 | ] 51 | 52 | ALL_DS_PATHS = INTERNAL_DS_PATHS + CONTRIBUTE_DS_PATHS + USER_DS_PATHS 53 | 54 | ROOT_MAPPING = { 55 | INTERNAL_DS_PATHS[0]: DataSourceCategory.BASE, 56 | CONTRIBUTE_DS_PATHS[0]: DataSourceCategory.CONTRIB, 57 | USER_DS_PATHS[0]: DataSourceCategory.USER, 58 | } 59 | 60 | 61 | class DataSourcesList: 62 | def __init__(self, ds_paths=ALL_DS_PATHS): 63 | self.data_sources = {} 64 | self.ds_paths = ds_paths 65 | self._fill_data_sources_list() 66 | 67 | def _fill_data_sources_list(self) -> None: 68 | """ 69 | Populate the internal dictionary of available data sources by scanning 70 | all configured data source directories. 71 | 72 | :return: None 73 | :rtype: None 74 | """ 75 | self.data_sources = {} 76 | for ds_path in self.ds_paths: 77 | for root, _dirs, files in os.walk(ds_path): 78 | ini_files = [file for file in files if file.endswith(".ini")] 79 | 80 | for ini_file in ini_files: 81 | ini_full_path = os.path.join(root, ini_file) 82 | 83 | try: 84 | ds = DataSourceSerializer.read_from_ini(ini_full_path) 85 | except Exception: 86 | logger.exception( 87 | f"Failed to parse INI file: {ini_full_path}" 88 | ) 89 | continue 90 | 91 | ds.category = ROOT_MAPPING.get( 92 | ds_path, DataSourceCategory.USER 93 | ) 94 | 95 | ds.action = QAction( 96 | QIcon(ds.icon_path), self.tr(ds.alias), None 97 | ) 98 | ds.action.setData(ds) 99 | 100 | self.data_sources[ds.id] = ds 101 | 102 | # noinspection PyMethodMayBeStatic 103 | def tr(self, message): 104 | try: 105 | message = str(message) 106 | except: 107 | return message 108 | # noinspection PyTypeChecker,PyArgumentList,PyCallByClass 109 | return CustomTranslator().translate("QuickMapServices", message) 110 | -------------------------------------------------------------------------------- /src/quick_map_services/group_edit_dialog.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | Dialog 4 | 5 | 6 | 7 | 0 8 | 0 9 | 355 10 | 164 11 | 12 | 13 | 14 | Dialog 15 | 16 | 17 | 18 | 19 | 20 | 0 21 | 22 | 23 | 24 | General 25 | 26 | 27 | 28 | QFormLayout::ExpandingFieldsGrow 29 | 30 | 31 | 32 | 33 | ID 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | Alias 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | Icon 54 | 55 | 56 | 57 | 58 | 59 | 60 | 30 61 | 62 | 63 | QLayout::SetDefaultConstraint 64 | 65 | 66 | 0 67 | 68 | 69 | 70 | 71 | 72 | 24 73 | 24 74 | 75 | 76 | 77 | Icon 78 | 79 | 80 | true 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 0 89 | 0 90 | 91 | 92 | 93 | Choose 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | Qt::Horizontal 107 | 108 | 109 | QDialogButtonBox::Cancel|QDialogButtonBox::Ok 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | buttonBox 119 | accepted() 120 | Dialog 121 | accept() 122 | 123 | 124 | 248 125 | 254 126 | 127 | 128 | 157 129 | 274 130 | 131 | 132 | 133 | 134 | buttonBox 135 | rejected() 136 | Dialog 137 | reject() 138 | 139 | 140 | 316 141 | 260 142 | 143 | 144 | 286 145 | 274 146 | 147 | 148 | 149 | 150 | 151 | -------------------------------------------------------------------------------- /src/quick_map_services/file_selection_widget.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """ 4 | *************************************************************************** 5 | FileSelectionPanel.py 6 | --------------------- 7 | Date : August 2012 8 | Copyright : (C) 2012 by Victor Olaya 9 | Email : volayaf at gmail dot com 10 | *************************************************************************** 11 | * * 12 | * This program is free software; you can redistribute it and/or modify * 13 | * it under the terms of the GNU General Public License as published by * 14 | * the Free Software Foundation; either version 2 of the License, or * 15 | * (at your option) any later version. * 16 | * * 17 | *************************************************************************** 18 | """ 19 | 20 | from quick_map_services.core.settings import QmsSettings 21 | 22 | __author__ = "Victor Olaya" 23 | __date__ = "August 2012" 24 | __copyright__ = "(C) 2012, Victor Olaya" 25 | 26 | # This will get replaced with a git SHA1 when you do a git archive 27 | 28 | __revision__ = "$Format:%H$" 29 | 30 | import os 31 | 32 | from qgis.PyQt import QtCore 33 | from qgis.PyQt.QtCore import pyqtSlot 34 | from qgis.PyQt.QtWidgets import ( 35 | QFileDialog, 36 | QHBoxLayout, 37 | QLineEdit, 38 | QToolButton, 39 | QWidget, 40 | ) 41 | 42 | try: 43 | _fromUtf8 = QtCore.QString.fromUtf8 44 | except AttributeError: 45 | 46 | def _fromUtf8(s): 47 | return s 48 | 49 | 50 | class FileSelectionWidget(QWidget): 51 | def __init__(self, parent=None): 52 | super(FileSelectionWidget, self).__init__(None) 53 | self.setupUi(self) 54 | 55 | self.ext = "*" 56 | self.dialog_title = self.tr("Select folder") 57 | self.is_folder = False 58 | 59 | self.btnSelect.clicked.connect(self.show_selection_dialog) 60 | 61 | @pyqtSlot() 62 | def show_selection_dialog(self) -> None: 63 | """ 64 | Display a file or folder selection dialog 65 | and update the stored last used path. 66 | 67 | Updates the stored path after a successful selection. 68 | 69 | :return: None 70 | :rtype: None 71 | """ 72 | settings = QmsSettings() 73 | 74 | text = self.leText.text() 75 | if os.path.isdir(text): 76 | path = text 77 | elif os.path.isdir(os.path.dirname(text)): 78 | path = os.path.dirname(text) 79 | else: 80 | path = settings.last_icon_path 81 | 82 | if self.is_folder: 83 | folder = QFileDialog.getExistingDirectory( 84 | self, self.dialog_title, path 85 | ) 86 | if folder: 87 | self.leText.setText(folder) 88 | settings.last_icon_path = os.path.dirname(folder) 89 | else: 90 | filename, _ = QFileDialog.getOpenFileName( 91 | self, self.dialog_title, path, self.ext 92 | ) 93 | if filename: 94 | self.leText.setText(filename) 95 | settings.last_icon_path = os.path.dirname(filename) 96 | 97 | def get_path(self): 98 | s = self.leText.text() 99 | if os.name == "nt": 100 | s = s.replace("\\", "/") 101 | return s 102 | 103 | def set_path(self, text): 104 | self.leText.setText(text) 105 | 106 | def set_dialog_ext(self, ext): 107 | self.ext = ext 108 | 109 | def set_dialog_title(self, title): 110 | self.dialog_title = title 111 | 112 | def setupUi(self, Form): 113 | Form.setObjectName(_fromUtf8("Form")) 114 | Form.resize(249, 23) 115 | self.horizontalLayout = QHBoxLayout(Form) 116 | self.horizontalLayout.setSpacing(2) 117 | self.horizontalLayout.setMargin(0) 118 | self.horizontalLayout.setObjectName(_fromUtf8("horizontalLayout")) 119 | self.leText = QLineEdit(Form) 120 | self.leText.setReadOnly(True) 121 | self.leText.setObjectName(_fromUtf8("leText")) 122 | self.horizontalLayout.addWidget(self.leText) 123 | self.btnSelect = QToolButton(Form) 124 | self.btnSelect.setObjectName(_fromUtf8("btnSelect")) 125 | self.horizontalLayout.addWidget(self.btnSelect) 126 | 127 | self.retranslateUi(Form) 128 | QtCore.QMetaObject.connectSlotsByName(Form) 129 | 130 | def retranslateUi(self, Form): 131 | # Form.setWindowTitle(self.tr(self.dialog_title)) 132 | self.btnSelect.setText(self.tr("...")) 133 | -------------------------------------------------------------------------------- /src/quick_map_services/help/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | REM Command file for Sphinx documentation 4 | 5 | if "%SPHINXBUILD%" == "" ( 6 | set SPHINXBUILD=sphinx-build 7 | ) 8 | set BUILDDIR=build 9 | set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% source 10 | if NOT "%PAPER%" == "" ( 11 | set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS% 12 | ) 13 | 14 | if "%1" == "" goto help 15 | 16 | if "%1" == "help" ( 17 | :help 18 | echo.Please use `make ^` where ^ is one of 19 | echo. html to make standalone HTML files 20 | echo. dirhtml to make HTML files named index.html in directories 21 | echo. singlehtml to make a single large HTML file 22 | echo. pickle to make pickle files 23 | echo. json to make JSON files 24 | echo. htmlhelp to make HTML files and a HTML help project 25 | echo. qthelp to make HTML files and a qthelp project 26 | echo. devhelp to make HTML files and a Devhelp project 27 | echo. epub to make an epub 28 | echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter 29 | echo. text to make text files 30 | echo. man to make manual pages 31 | echo. changes to make an overview over all changed/added/deprecated items 32 | echo. linkcheck to check all external links for integrity 33 | echo. doctest to run all doctests embedded in the documentation if enabled 34 | goto end 35 | ) 36 | 37 | if "%1" == "clean" ( 38 | for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i 39 | del /q /s %BUILDDIR%\* 40 | goto end 41 | ) 42 | 43 | if "%1" == "html" ( 44 | %SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html 45 | echo. 46 | echo.Build finished. The HTML pages are in %BUILDDIR%/html. 47 | goto end 48 | ) 49 | 50 | if "%1" == "dirhtml" ( 51 | %SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml 52 | echo. 53 | echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml. 54 | goto end 55 | ) 56 | 57 | if "%1" == "singlehtml" ( 58 | %SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml 59 | echo. 60 | echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml. 61 | goto end 62 | ) 63 | 64 | if "%1" == "pickle" ( 65 | %SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle 66 | echo. 67 | echo.Build finished; now you can process the pickle files. 68 | goto end 69 | ) 70 | 71 | if "%1" == "json" ( 72 | %SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json 73 | echo. 74 | echo.Build finished; now you can process the JSON files. 75 | goto end 76 | ) 77 | 78 | if "%1" == "htmlhelp" ( 79 | %SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp 80 | echo. 81 | echo.Build finished; now you can run HTML Help Workshop with the ^ 82 | .hhp project file in %BUILDDIR%/htmlhelp. 83 | goto end 84 | ) 85 | 86 | if "%1" == "qthelp" ( 87 | %SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp 88 | echo. 89 | echo.Build finished; now you can run "qcollectiongenerator" with the ^ 90 | .qhcp project file in %BUILDDIR%/qthelp, like this: 91 | echo.^> qcollectiongenerator %BUILDDIR%\qthelp\template_class.qhcp 92 | echo.To view the help file: 93 | echo.^> assistant -collectionFile %BUILDDIR%\qthelp\template_class.ghc 94 | goto end 95 | ) 96 | 97 | if "%1" == "devhelp" ( 98 | %SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp 99 | echo. 100 | echo.Build finished. 101 | goto end 102 | ) 103 | 104 | if "%1" == "epub" ( 105 | %SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub 106 | echo. 107 | echo.Build finished. The epub file is in %BUILDDIR%/epub. 108 | goto end 109 | ) 110 | 111 | if "%1" == "latex" ( 112 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 113 | echo. 114 | echo.Build finished; the LaTeX files are in %BUILDDIR%/latex. 115 | goto end 116 | ) 117 | 118 | if "%1" == "text" ( 119 | %SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text 120 | echo. 121 | echo.Build finished. The text files are in %BUILDDIR%/text. 122 | goto end 123 | ) 124 | 125 | if "%1" == "man" ( 126 | %SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man 127 | echo. 128 | echo.Build finished. The manual pages are in %BUILDDIR%/man. 129 | goto end 130 | ) 131 | 132 | if "%1" == "changes" ( 133 | %SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes 134 | echo. 135 | echo.The overview file is in %BUILDDIR%/changes. 136 | goto end 137 | ) 138 | 139 | if "%1" == "linkcheck" ( 140 | %SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck 141 | echo. 142 | echo.Link check complete; look for any errors in the above output ^ 143 | or in %BUILDDIR%/linkcheck/output.txt. 144 | goto end 145 | ) 146 | 147 | if "%1" == "doctest" ( 148 | %SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest 149 | echo. 150 | echo.Testing of doctests in the sources finished, look at the ^ 151 | results in %BUILDDIR%/doctest/output.txt. 152 | goto end 153 | ) 154 | 155 | :end 156 | -------------------------------------------------------------------------------- /src/quick_map_services/rb_result_renderer.py: -------------------------------------------------------------------------------- 1 | """ 2 | /*************************************************************************** 3 | RuGeocoder 4 | A QGIS plugin 5 | Geocode your csv files to shp 6 | ------------------- 7 | begin : 2012-02-20 8 | copyright : (C) 2012 by Nikulin Evgeniy 9 | email : nikulin.e at gmail 10 | ***************************************************************************/ 11 | 12 | /*************************************************************************** 13 | * * 14 | * This program is free software; you can redistribute it and/or modify * 15 | * it under the terms of the GNU General Public License as published by * 16 | * the Free Software Foundation; either version 2 of the License, or * 17 | * (at your option) any later version. * 18 | * * 19 | ***************************************************************************/ 20 | """ 21 | 22 | from qgis.core import ( 23 | QgsCoordinateReferenceSystem, 24 | QgsCoordinateTransform, 25 | QgsProject, 26 | QgsRectangle, 27 | ) 28 | from qgis.gui import QgsRubberBand 29 | from qgis.PyQt.QtGui import QColor 30 | from qgis.utils import iface 31 | 32 | from quick_map_services.core.compat import GeometryType 33 | 34 | 35 | class RubberBandResultRenderer: 36 | def __init__(self): 37 | self.iface = iface 38 | 39 | self.srs_wgs84 = QgsCoordinateReferenceSystem.fromEpsgId(4326) 40 | self.transform_decorator = QgsCoordinateTransform( 41 | self.srs_wgs84, self.srs_wgs84, QgsProject.instance() 42 | ) 43 | 44 | self.rb = QgsRubberBand(self.iface.mapCanvas(), GeometryType.Point) 45 | self.rb.setColor(QColor("magenta")) 46 | self.rb.setIconSize(12) 47 | 48 | self.features_rb = QgsRubberBand( 49 | self.iface.mapCanvas(), GeometryType.Point 50 | ) 51 | magenta_transp = QColor("#3388ff") 52 | magenta_transp.setAlpha(120) 53 | self.features_rb.setColor(magenta_transp) 54 | self.features_rb.setIconSize(12) 55 | self.features_rb.setWidth(2) 56 | 57 | def show_point(self, point, center=False): 58 | # check srs 59 | if self.need_transform(): 60 | point = self.transform_point(point) 61 | 62 | self.rb.addPoint(point) 63 | if center: 64 | self.center_to_point(point) 65 | 66 | def clear(self): 67 | self.rb.reset(GeometryType.Point) 68 | 69 | def need_transform(self): 70 | return ( 71 | self.iface.mapCanvas().mapSettings().destinationCrs().postgisSrid() 72 | != 4326 73 | ) 74 | 75 | def transform_point(self, point): 76 | self.transform_decorator.setDestinationCrs( 77 | self.iface.mapCanvas().mapSettings().destinationCrs() 78 | ) 79 | try: 80 | return self.transform_decorator.transform(point) 81 | except: 82 | print("Error on transform!") # DEBUG! need message??? 83 | return 84 | 85 | def transform_bbox(self, bbox): 86 | self.transform_decorator.setDestinationCrs( 87 | self.iface.mapCanvas().mapSettings().destinationCrs() 88 | ) 89 | try: 90 | return self.transform_decorator.transformBoundingBox(bbox) 91 | except: 92 | print("Error on transform!") # DEBUG! need message??? 93 | return 94 | 95 | def transform_geom(self, geom): 96 | self.transform_decorator.setDestinationCrs( 97 | self.iface.mapCanvas().mapSettings().destinationCrs() 98 | ) 99 | try: 100 | geom.transform(self.transform_decorator) 101 | return geom 102 | except Exception as e: 103 | print("Error on transform! %s" % e) # DEBUG! need message??? 104 | return 105 | 106 | def center_to_point(self, point): 107 | canvas = self.iface.mapCanvas() 108 | new_extent = QgsRectangle(canvas.extent()) 109 | new_extent.scale(1, point) 110 | canvas.setExtent(new_extent) 111 | canvas.refresh() 112 | 113 | def zoom_to_bbox(self, bbox): 114 | if self.need_transform(): 115 | bbox = self.transform_bbox(bbox) 116 | self.iface.mapCanvas().setExtent(bbox) 117 | self.iface.mapCanvas().refresh() 118 | 119 | def show_feature(self, geom): 120 | if self.need_transform(): 121 | geom = self.transform_geom(geom) 122 | self.features_rb.setToGeometry(geom, None) 123 | 124 | def clear_feature(self): 125 | self.features_rb.reset(GeometryType.Point) 126 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # poetry 98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 102 | #poetry.lock 103 | 104 | # pdm 105 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 106 | #pdm.lock 107 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 108 | # in version control. 109 | # https://pdm.fming.dev/latest/usage/project/#working-with-version-control 110 | .pdm.toml 111 | .pdm-python 112 | .pdm-build/ 113 | 114 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 115 | __pypackages__/ 116 | 117 | # Celery stuff 118 | celerybeat-schedule 119 | celerybeat.pid 120 | 121 | # SageMath parsed files 122 | *.sage.py 123 | 124 | # Environments 125 | .env 126 | .venv 127 | env/ 128 | venv/ 129 | ENV/ 130 | env.bak/ 131 | venv.bak/ 132 | 133 | # Spyder project settings 134 | .spyderproject 135 | .spyproject 136 | 137 | # Rope project settings 138 | .ropeproject 139 | 140 | # mkdocs documentation 141 | /site 142 | 143 | # mypy 144 | .mypy_cache/ 145 | .dmypy.json 146 | dmypy.json 147 | 148 | # Pyre type checker 149 | .pyre/ 150 | 151 | # pytype static type analyzer 152 | .pytype/ 153 | 154 | # Cython debug symbols 155 | cython_debug/ 156 | 157 | # PyCharm 158 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 159 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 160 | # and can be added to the global gitignore or merged into this file. For a more nuclear 161 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 162 | #.idea/ 163 | 164 | # C++ objects and libs 165 | *.slo 166 | *.lo 167 | *.o 168 | *.a 169 | *.la 170 | *.lai 171 | *.so 172 | *.so.* 173 | *.dll 174 | *.dylib 175 | 176 | # Qt-es 177 | object_script.*.Release 178 | object_script.*.Debug 179 | *_plugin_import.cpp 180 | /.qmake.cache 181 | /.qmake.stash 182 | *.pro.user 183 | *.pro.user.* 184 | *.qbs.user 185 | *.qbs.user.* 186 | *.moc 187 | moc_*.cpp 188 | moc_*.h 189 | qrc_*.cpp 190 | ui_*.h 191 | *.qmlc 192 | *.jsc 193 | Makefile* 194 | *build-* 195 | *.qm 196 | *.prl 197 | 198 | # Qt unit tests 199 | target_wrapper.* 200 | 201 | # QtCreator 202 | *.autosave 203 | 204 | # QtCreator Qml 205 | *.qmlproject.user 206 | *.qmlproject.user.* 207 | 208 | # QtCreator CMake 209 | CMakeLists.txt.user* 210 | 211 | # QtCreator 4.8< compilation database 212 | compile_commands.json 213 | 214 | # QtCreator local machine specific files for imported projects 215 | *creator.user* 216 | 217 | *_qmlcache.qrc 218 | 219 | # PyQt 220 | ui_*.py 221 | resources.py 222 | 223 | # QGIS 224 | *.db 225 | 226 | .vscode 227 | -------------------------------------------------------------------------------- /src/quick_map_services/quick_map_services_interface.py: -------------------------------------------------------------------------------- 1 | import configparser 2 | from abc import abstractmethod 3 | from pathlib import Path 4 | from typing import TYPE_CHECKING 5 | 6 | from qgis import utils 7 | from qgis.core import QgsApplication 8 | from qgis.PyQt.QtCore import QObject, QTranslator 9 | 10 | from quick_map_services.core.constants import PACKAGE_NAME 11 | from quick_map_services.core.logging import logger, unload_logger 12 | from quick_map_services.shared.qobject_metaclass import QObjectMetaClass 13 | 14 | if TYPE_CHECKING: 15 | from quick_map_services.notifier.notifier_interface import ( 16 | NotifierInterface, 17 | ) 18 | 19 | 20 | class QuickMapServicesInterface(QObject, metaclass=QObjectMetaClass): 21 | """Interface for the QuickMapServices plugin. 22 | 23 | This abstract base class provides singleton access to the plugin 24 | instance, exposes plugin metadata, version, and path, and defines 25 | abstract properties and methods that must be implemented by concrete 26 | subclasses. 27 | """ 28 | 29 | @classmethod 30 | def instance(cls) -> "QuickMapServicesInterface": 31 | """Return the singleton instance of the QuickMapServicesInterface plugin. 32 | 33 | :returns: The QuickMapServicesInterface plugin instance. 34 | :rtype: QuickMapServicesInterface 35 | :raises AssertionError: If the plugin has not been created yet. 36 | """ 37 | plugin = utils.plugins.get(PACKAGE_NAME) 38 | assert plugin is not None, "Using a plugin before it was created" 39 | return plugin 40 | 41 | @property 42 | def metadata(self) -> configparser.ConfigParser: 43 | """Return the parsed metadata for the plugin. 44 | 45 | :returns: Parsed metadata as a ConfigParser object. 46 | :rtype: configparser.ConfigParser 47 | """ 48 | metadata = utils.plugins_metadata_parser.get(PACKAGE_NAME) 49 | assert metadata is not None, "Using a plugin before it was created" 50 | return metadata 51 | 52 | @property 53 | def version(self) -> str: 54 | """Return the plugin version. 55 | 56 | :returns: Plugin version string. 57 | :rtype: str 58 | """ 59 | return self.metadata.get("general", "version") 60 | 61 | @property 62 | def path(self) -> "Path": 63 | """Return the plugin path. 64 | 65 | :returns: Path to the plugin directory. 66 | :rtype: Path 67 | """ 68 | return Path(__file__).parent 69 | 70 | @property 71 | @abstractmethod 72 | def notifier(self) -> "NotifierInterface": 73 | """Return the notifier for displaying messages to the user. 74 | 75 | :returns: Notifier interface instance. 76 | :rtype: NotifierInterface 77 | """ 78 | ... 79 | 80 | def initGui(self) -> None: 81 | """Initialize the GUI components and load necessary resources.""" 82 | self.__translators = list() 83 | 84 | try: 85 | self._load() 86 | except Exception: 87 | logger.exception("An error occurred while plugin loading") 88 | 89 | def unload(self) -> None: 90 | """Unload the plugin and perform cleanup operations.""" 91 | try: 92 | self._unload() 93 | except Exception: 94 | logger.exception("An error occurred while plugin unloading") 95 | 96 | self.__unload_translations() 97 | unload_logger() 98 | 99 | @abstractmethod 100 | def _load(self) -> None: 101 | """Load the plugin resources and initialize components. 102 | 103 | This method must be implemented by subclasses. 104 | """ 105 | ... 106 | 107 | @abstractmethod 108 | def _unload(self) -> None: 109 | """Unload the plugin resources and clean up components. 110 | 111 | This method must be implemented by subclasses. 112 | """ 113 | ... 114 | 115 | def _add_translator(self, translator_path: Path) -> None: 116 | """Add a translator for the plugin. 117 | 118 | :param translator_path: Path to the translation file. 119 | :type translator_path: Path 120 | """ 121 | translator = QTranslator() 122 | is_loaded = translator.load(str(translator_path)) 123 | if not is_loaded: 124 | logger.debug(f"Translator {translator_path} wasn't loaded") 125 | return 126 | 127 | is_installed = QgsApplication.installTranslator(translator) 128 | if not is_installed: 129 | logger.error(f"Translator {translator_path} wasn't installed") 130 | return 131 | 132 | # Should be kept in memory 133 | self.__translators.append(translator) 134 | 135 | def __unload_translations(self) -> None: 136 | """Remove all translators added by the plugin.""" 137 | for translator in self.__translators: 138 | QgsApplication.removeTranslator(translator) 139 | self.__translators.clear() 140 | -------------------------------------------------------------------------------- /src/quick_map_services/qms_external_api_python/api/api_base.py: -------------------------------------------------------------------------------- 1 | import json 2 | from typing import Any, Dict, Optional 3 | 4 | from qgis.core import QgsNetworkAccessManager 5 | from qgis.PyQt.QtCore import QUrl, QUrlQuery 6 | from qgis.PyQt.QtNetwork import QNetworkReply, QNetworkRequest 7 | 8 | from quick_map_services.core.settings import QmsSettings 9 | from quick_map_services.qms_external_api_python.api.qt_network_error import ( 10 | QtNetworkError, 11 | ) 12 | 13 | 14 | class QmsNews: 15 | """ 16 | Represents localized news content for QuickMapServices. 17 | """ 18 | 19 | def __init__(self, i18n_texts: Dict[str, str]) -> None: 20 | """ 21 | Initialize QmsNews with translations. 22 | 23 | :param i18n_texts: Dictionary mapping language codes to text. 24 | :type i18n_texts: Dict[str, str] 25 | """ 26 | self.i18n_texts = i18n_texts 27 | 28 | def get_text(self, lang: str) -> Optional[str]: 29 | """ 30 | Retrieve news text in the specified language. 31 | 32 | :param lang: Language code (e.g., "en", "ru"). 33 | :type lang: str 34 | 35 | :return: Localized news text or None if not available. 36 | :rtype: Optional[str] 37 | """ 38 | return self.i18n_texts.get(lang, self.i18n_texts.get("en")) 39 | 40 | 41 | class ApiClient: 42 | """ 43 | Base API client for QuickMapServices. 44 | 45 | This class performs HTTP requests through 46 | :class:`QgsNetworkAccessManager`, ensuring full integration 47 | with QGIS network settings and proxy configuration. 48 | """ 49 | 50 | VERSION: int = 0 51 | 52 | def __init__(self, endpoint_url: Optional[str] = None) -> None: 53 | """ 54 | Initialize the API client. 55 | 56 | :param endpoint_url: Base API endpoint URL. 57 | :type endpoint_url: Optional[str] 58 | """ 59 | settings = QmsSettings() 60 | self.endpoint_url = ( 61 | endpoint_url if endpoint_url is not None else settings.endpoint_url 62 | ) 63 | 64 | @property 65 | def base_url(self) -> str: 66 | """ 67 | Construct the base URL including the API version. 68 | 69 | :return: Base API URL. 70 | :rtype: str 71 | """ 72 | return f"{self.endpoint_url}/api/v{self.VERSION}/" 73 | 74 | def full_url(self, sub_url: str) -> str: 75 | """ 76 | Build a full URL from the base endpoint and a subpath. 77 | 78 | :param sub_url: Relative path for the request. 79 | :type sub_url: str 80 | 81 | :return: Fully qualified API URL. 82 | :rtype: str 83 | """ 84 | return f"{self.base_url}{sub_url}" 85 | 86 | def _get_content( 87 | self, url: str, params: Optional[Dict[str, Any]] = None 88 | ) -> bytes: 89 | """ 90 | Perform a blocking GET request and return raw binary content. 91 | 92 | :param url: The full URL to request. 93 | :type url: str 94 | :param params: Optional dictionary of query parameters. 95 | :type params: Optional[Dict[str, Any]] 96 | 97 | :return: Raw response content as bytes. 98 | :rtype: bytes 99 | """ 100 | qurl_query = QUrlQuery() 101 | params = {} if params is None else params 102 | for key, value in params.items(): 103 | qurl_query.addQueryItem(str(key), str(value)) 104 | qurl = QUrl(url) 105 | qurl.setQuery(qurl_query) 106 | 107 | request = QNetworkRequest(qurl) 108 | response = QgsNetworkAccessManager.instance().blockingGet(request) 109 | 110 | if response.error() != QNetworkReply.NetworkError.NoError: 111 | error = QtNetworkError.from_qt(response.error()) 112 | error_code = error.value.code 113 | message = error.value.description if error is not None else "" 114 | raise ConnectionError(error_code, message) 115 | 116 | return response.content().data() 117 | 118 | def _get_json( 119 | self, url: str, params: Optional[Dict[str, Any]] = None 120 | ) -> Any: 121 | """ 122 | Perform a GET request and return decoded JSON data. 123 | 124 | :param url: The full URL to request. 125 | :type url: str 126 | :param params: Optional query parameters. 127 | :type params: Optional[Dict[str, Any]] 128 | 129 | :return: Parsed JSON response. 130 | :rtype: Any 131 | """ 132 | content = self._get_content(url, params) 133 | return json.loads(content.decode("utf-8")) 134 | 135 | def get_news(self) -> Optional["QmsNews"]: 136 | """ 137 | Retrieve localized news information from the static QMS endpoint. 138 | 139 | :return: QmsNews instance or None if unavailable. 140 | :rtype: Optional[QmsNews] 141 | """ 142 | url = f"{self.endpoint_url}/static/news.json" 143 | try: 144 | content = self._get_content(url) 145 | news_json = json.loads(content.decode("utf-8")) 146 | return QmsNews( 147 | { 148 | "en": news_json.get("text_en"), 149 | "ru": news_json.get("text_ru"), 150 | } 151 | ) 152 | except Exception: 153 | return None 154 | -------------------------------------------------------------------------------- /src/quick_map_services/qms_external_api_python/api/api_v1.py: -------------------------------------------------------------------------------- 1 | from quick_map_services.qms_external_api_python.api.api_base import ( 2 | ApiClient, 3 | ) 4 | 5 | 6 | class ApiClientV1(ApiClient): 7 | VERSION = 1 8 | 9 | def get_geoservices( 10 | self, 11 | type_filter=None, 12 | epsg_filter=None, 13 | search_str=None, 14 | intersects_boundary=None, 15 | cumulative_status=None, 16 | limit=None, 17 | offset=None, 18 | ): 19 | """ 20 | Geoservices list retrieve 21 | :param type: Type of geoservice - ['tms' | 'wms' | 'wfs' | 'geojson'] 22 | :param epsg: EPSG code of geoservice CRS - any integer. Example: 4326, 3857 23 | :param search_str: Search name or description. Examples: 'osm', 'satellite', 'transport' 24 | :param intersects_boundary: Geom (WKT or EWKT format) for filter by intersects with boundary 25 | :param cumulative_status: Status of service: ['works' | 'problematic' | 'failed'] 26 | :return: List of geoservices 27 | """ 28 | sub_url = "geoservices/" 29 | params = {} 30 | if type_filter: 31 | params["type"] = type_filter 32 | if epsg_filter: 33 | params["epsg"] = epsg_filter 34 | if search_str: 35 | params["search"] = search_str 36 | if intersects_boundary: 37 | params["intersects_boundary"] = intersects_boundary 38 | if cumulative_status: 39 | params["cumulative_status"] = cumulative_status 40 | if limit: 41 | params["limit"] = limit 42 | if offset is not None: 43 | params["offset"] = offset 44 | 45 | return self._get_json(self.full_url(sub_url), params) 46 | 47 | def geoservice_info_url(self, gs_id): 48 | sub_url = f"geoservices/{gs_id}/" 49 | return self.endpoint_url + "/" + sub_url 50 | 51 | def geoservice_report_url(self, gs_id): 52 | return self.geoservice_info_url(gs_id) + "?show-report-problem=1" 53 | 54 | def search_geoservices(self, search_str, intersects_boundary=None): 55 | """ 56 | Shortcut for search geoservices methods 57 | :param search_str: Search name or description 58 | :return: List of geoservices 59 | """ 60 | return self.get_geoservices( 61 | search_str=search_str, intersects_boundary=intersects_boundary 62 | ) 63 | 64 | def get_geoservice_info(self, geoservice): 65 | """ 66 | Retrieve geoservice info 67 | :param geoservice: geoservice id or geoservice object 68 | :return: geoservice info object 69 | """ 70 | if isinstance(geoservice, int) or isinstance(geoservice, str): 71 | gs_id = geoservice 72 | elif hasattr(geoservice, "id"): 73 | gs_id = geoservice.id 74 | elif hasattr(geoservice, "__iter__") and "id" in geoservice: 75 | gs_id = geoservice["id"] 76 | 77 | else: 78 | raise ValueError("Invalid geoservice argument") 79 | 80 | sub_url = f"geoservices/{gs_id}/" 81 | return self._get_json(self.full_url(sub_url)) 82 | 83 | def get_icons(self, search_str=None): 84 | """ 85 | Retrive icons list 86 | :param search_str: Search name. Examples: 'osm' 87 | :return: icons list 88 | """ 89 | sub_url = "icons" 90 | params = {} 91 | 92 | if search_str: 93 | params["search"] = search_str 94 | 95 | return self._get_json(self.full_url(sub_url), params) 96 | 97 | def get_icon_info(self, icon): 98 | """ 99 | Retrieve icon info 100 | :param icon: icon id or icon object 101 | :return: icon info object 102 | """ 103 | if isinstance(icon, int) or isinstance(icon, str): 104 | icon_id = icon 105 | elif hasattr(icon, "id"): 106 | icon_id = icon.id 107 | elif hasattr(icon, "__iter__") and "id" in icon: 108 | icon_id = icon["id"] 109 | 110 | else: 111 | raise ValueError("Invalid icon argument") 112 | 113 | sub_url = "icons/" + str(icon_id) 114 | return self._get_json(self.full_url(sub_url)) 115 | 116 | def get_icon_content(self, icon, width=32, height=32): 117 | """ 118 | Retrieve icon content 119 | :param icon: icon id or icon object 120 | :return: icon img 121 | """ 122 | if isinstance(icon, int) or isinstance(icon, str): 123 | icon_id = icon 124 | elif hasattr(icon, "id"): 125 | icon_id = icon.id 126 | elif hasattr(icon, "__iter__") and "id" in icon: 127 | icon_id = icon["id"] 128 | 129 | else: 130 | raise ValueError("Invalid icon argument") 131 | 132 | sub_url = "icons/%s/content" % str(icon_id) 133 | 134 | params = {"width": width, "height": height} 135 | 136 | content = self._get_content(self.full_url(sub_url), params=params) 137 | return content 138 | 139 | def get_default_icon(self, width=32, height=32): 140 | """ 141 | Retrieve default icon content 142 | :return: default icon img 143 | """ 144 | sub_url = "icons/default" 145 | params = {"width": width, "height": height} 146 | 147 | content = self._get_content(self.full_url(sub_url), params=params) 148 | return content 149 | -------------------------------------------------------------------------------- /src/quick_map_services/gui/editor_widget_tms.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from qgis.PyQt import uic 4 | from qgis.PyQt.QtGui import QIntValidator 5 | from qgis.PyQt.QtWidgets import QApplication, QMessageBox, QWidget 6 | 7 | from .line_edit_color_validator import LineEditColorValidator 8 | 9 | FORM_CLASS, _ = uic.loadUiType( 10 | os.path.join(os.path.dirname(__file__), "editor_widget_tms.ui") 11 | ) 12 | 13 | 14 | class EditorWidgetTms(QWidget, FORM_CLASS): 15 | def __init__(self, parent=None): 16 | super(EditorWidgetTms, self).__init__(parent) 17 | self.setupUi(self) 18 | self.tms_validator = LineEditColorValidator( 19 | self.txtUrl, 20 | "http[s]?://.+", 21 | error_tooltip="http{s}://any_text/{z}/{x}/{y}/", 22 | ) 23 | self.txtCrsId.setValidator(QIntValidator()) 24 | self.txtPostgisCrsId.setValidator(QIntValidator()) 25 | 26 | QApplication.instance().focusChanged.connect(self.focus_changed) 27 | 28 | def focus_changed(self, old_w, new_w): 29 | remap = { 30 | self.txtCrsId: self.rbCrsId, 31 | self.txtPostgisCrsId: self.rbPostgisCrsId, 32 | self.spnCustomProj: self.rbCustomProj, 33 | } 34 | 35 | for cont, rb in remap.items(): 36 | if new_w == cont: 37 | rb.setChecked(True) 38 | 39 | def feel_form(self, ds_info): 40 | self.ds_info = ds_info 41 | 42 | self.txtUrl.setText(ds_info.tms_url) 43 | self.spbZMin.setValue( 44 | int(ds_info.tms_zmin) if ds_info.tms_zmin else self.spbZMin.value() 45 | ) 46 | self.spbZMax.setValue( 47 | int(ds_info.tms_zmax) if ds_info.tms_zmax else self.spbZMax.value() 48 | ) 49 | self.chkOriginTop.setChecked( 50 | True 51 | if ( 52 | ds_info.tms_y_origin_top is None 53 | or ds_info.tms_y_origin_top == 1 54 | ) 55 | else False 56 | ) 57 | 58 | if self.ds_info.tms_epsg_crs_id: 59 | self.txtCrsId.setText(str(self.ds_info.tms_epsg_crs_id)) 60 | self.rbCrsId.setChecked(True) 61 | return 62 | if self.ds_info.tms_postgis_crs_id: 63 | self.txtPostgisCrsId.setText(str(self.ds_info.tms_postgis_crs_id)) 64 | self.rbPostgisCrsId.setChecked(True) 65 | return 66 | if self.ds_info.tms_custom_proj: 67 | self.spnCustomProj.setText(self.ds_info.tms_custom_proj) 68 | self.rbCustomProj.setChecked(True) 69 | return 70 | # not setted. set default 71 | self.txtCrsId.setText(str(3857)) 72 | self.rbCrsId.setChecked(True) 73 | 74 | def feel_ds_info(self, ds_info): 75 | ds_info.tms_url = self.txtUrl.text() 76 | ds_info.tms_zmin = self.spbZMin.value() 77 | ds_info.tms_zmax = self.spbZMax.value() 78 | ds_info.tms_y_origin_top = int(self.chkOriginTop.isChecked()) 79 | 80 | if self.rbCrsId.isChecked(): 81 | try: 82 | code = int(self.txtCrsId.text()) 83 | if code != 3857: 84 | ds_info.tms_epsg_crs_id = code 85 | except: 86 | pass 87 | return 88 | if self.rbPostgisCrsId.isChecked(): 89 | try: 90 | code = int(self.txtPostgisCrsId.text()) 91 | if code != 3857: 92 | ds_info.tms_postgis_crs_id = code 93 | except: 94 | pass 95 | return 96 | if self.rbCustomProj.isChecked(): 97 | ds_info.tms_custom_proj = self.spnCustomProj.text() 98 | return 99 | 100 | def validate(self, ds_info): 101 | """ 102 | Validates the TMS editor widget fields. 103 | 104 | :param ds_info: DataSourceInfo object to validate 105 | :type ds_info: DataSourceInfo 106 | :returns: True if all fields are valid, False otherwise 107 | :rtype: bool 108 | """ 109 | if not ds_info.tms_url: 110 | QMessageBox.critical( 111 | self, 112 | self.tr("Error on save data source"), 113 | self.tr("Please, enter TMS url"), 114 | ) 115 | return False 116 | 117 | if not self.tms_validator.is_valid(): 118 | QMessageBox.critical( 119 | self, 120 | self.tr("Error on save data source"), 121 | self.tr("Please, enter correct value for TMS url"), 122 | ) 123 | return False 124 | 125 | # Validate CRS 126 | 127 | if self.rbCrsId.isChecked(): 128 | try: 129 | code = int(self.txtCrsId.text()) 130 | except: 131 | QMessageBox.critical( 132 | self, 133 | self.tr("Error on save data source"), 134 | self.tr("Please, enter correct CRC ID"), 135 | ) 136 | return False 137 | 138 | if self.rbPostgisCrsId.isChecked(): 139 | try: 140 | code = int(self.txtPostgisCrsId.text()) 141 | except: 142 | QMessageBox.critical( 143 | self, 144 | self.tr("Error on save data source"), 145 | self.tr("Please, enter correct PostGIS CRC ID"), 146 | ) 147 | return False 148 | 149 | if self.rbCustomProj.isChecked(): 150 | if not self.spnCustomProj.text().strip(): 151 | QMessageBox.critical( 152 | self, 153 | self.tr("Error on save data source"), 154 | self.tr("Please, enter custom projection"), 155 | ) 156 | return False 157 | 158 | return True 159 | -------------------------------------------------------------------------------- /src/quick_map_services/qms_service_toolbox.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | QmsServiceToolbox 4 | 5 | 6 | 7 | 0 8 | 0 9 | 324 10 | 471 11 | 12 | 13 | 14 | Search NextGIS QMS 15 | 16 | 17 | 18 | 19 | 4 20 | 21 | 22 | 0 23 | 24 | 25 | 0 26 | 27 | 28 | 0 29 | 30 | 31 | 4 32 | 33 | 34 | 35 | 36 | 6 37 | 38 | 39 | 0 40 | 41 | 42 | 43 | 44 | Enter part of service's name 45 | 46 | 47 | 48 | 49 | 50 | 51 | 0 52 | 53 | 54 | 55 | 6 56 | 57 | 58 | 0 59 | 60 | 61 | 0 62 | 63 | 64 | 0 65 | 66 | 67 | 0 68 | 69 | 70 | 71 | 72 | Qt::Horizontal 73 | 74 | 75 | 76 | 40 77 | 20 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | Filter by extent 86 | 87 | 88 | true 89 | 90 | 91 | false 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 0 106 | 0 107 | 108 | 109 | 110 | true 111 | 112 | 113 | 114 | 24 115 | 24 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | QFrame::StyledPanel 129 | 130 | 131 | QFrame::Raised 132 | 133 | 134 | 135 | 0 136 | 137 | 138 | 0 139 | 140 | 141 | 6 142 | 143 | 144 | 0 145 | 146 | 147 | 6 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | Qt::AlignCenter 159 | 160 | 161 | 0 162 | 163 | 164 | true 165 | 166 | 167 | 168 | 169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | QgsFilterLineEdit 177 | QLineEdit 178 |
qgsfilterlineedit.h
179 |
180 |
181 | 182 | 183 |
184 | -------------------------------------------------------------------------------- /src/quick_map_services/icons/qms_logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 38 | 43 | 48 | 52 | 57 | 62 | 63 | 64 | -------------------------------------------------------------------------------- /src/quick_map_services/gui/user_services_box.py: -------------------------------------------------------------------------------- 1 | import shutil 2 | from pathlib import Path 3 | from typing import Optional 4 | 5 | from qgis.core import QgsApplication 6 | from qgis.PyQt import uic 7 | from qgis.PyQt.QtCore import Qt 8 | from qgis.PyQt.QtWidgets import ( 9 | QDialog, 10 | QGroupBox, 11 | QHeaderView, 12 | QListWidgetItem, 13 | QMessageBox, 14 | QTreeView, 15 | QVBoxLayout, 16 | QWidget, 17 | ) 18 | 19 | from quick_map_services.data_sources_list import USER_DS_PATHS, DataSourcesList 20 | from quick_map_services.data_sources_model import DSManagerModel 21 | from quick_map_services.ds_edit_dialog import DsEditDialog 22 | 23 | FORM_CLASS, _ = uic.loadUiType( 24 | str(Path(__file__).parent / "user_services_box.ui") 25 | ) 26 | 27 | 28 | class UserServicesBox(QGroupBox, FORM_CLASS): 29 | """ 30 | Widget for managing user-defined map services in QuickMapServices. 31 | """ 32 | 33 | def __init__(self, parent: Optional[QWidget] = None) -> None: 34 | """ 35 | Initialize the user services management box. 36 | 37 | :param parent: Optional parent widget. 38 | :type parent: Optional[QWidget] 39 | """ 40 | super(UserServicesBox, self).__init__(parent) 41 | self.setupUi(self) 42 | 43 | self.feel_list() 44 | 45 | self.lstServices.currentItemChanged.connect(self.on_sel_changed) 46 | self.lstServices.itemDoubleClicked.connect(self.on_edit) 47 | self.btnEdit.clicked.connect(self.on_edit) 48 | self.btnAdd.clicked.connect(self.on_add) 49 | self.btnDelete.clicked.connect(self.on_delete) 50 | self.btnCopy.clicked.connect(self.on_copy) 51 | 52 | self.btnAdd.setIcon(QgsApplication.getThemeIcon("symbologyAdd.svg")) 53 | self.btnEdit.setIcon(QgsApplication.getThemeIcon("symbologyEdit.svg")) 54 | self.btnDelete.setIcon( 55 | QgsApplication.getThemeIcon("symbologyRemove.svg") 56 | ) 57 | self.btnCopy.setIcon( 58 | QgsApplication.getThemeIcon("mActionEditCopy.svg") 59 | ) 60 | 61 | self.ds_model = DSManagerModel() 62 | 63 | def feel_list(self): 64 | self.lstServices.clear() 65 | ds_list = DataSourcesList(USER_DS_PATHS) 66 | for ds in ds_list.data_sources.values(): 67 | item = QListWidgetItem(ds.action.icon(), ds.action.text()) 68 | item.setData(Qt.ItemDataRole.UserRole, ds) 69 | self.lstServices.addItem(item) 70 | 71 | def on_sel_changed(self, curr, prev): 72 | has_sel = curr is not None 73 | self.btnEdit.setEnabled(has_sel) 74 | self.btnDelete.setEnabled(has_sel) 75 | 76 | def on_add(self): 77 | edit_dialog = DsEditDialog() 78 | edit_dialog.setWindowTitle(self.tr("Create service")) 79 | if edit_dialog.exec() == QDialog.DialogCode.Accepted: 80 | self.feel_list() 81 | self.ds_model.resetModel() 82 | 83 | def on_edit(self): 84 | item = self.lstServices.currentItem().data(Qt.ItemDataRole.UserRole) 85 | edit_dialog = DsEditDialog() 86 | edit_dialog.setWindowTitle(self.tr("Edit service")) 87 | edit_dialog.set_ds_info(item) 88 | if edit_dialog.exec() == QDialog.DialogCode.Accepted: 89 | self.feel_list() 90 | self.ds_model.resetModel() 91 | 92 | def on_delete(self): 93 | """ 94 | Delete the currently selected user service after confirmation. 95 | """ 96 | res = QMessageBox.question( 97 | None, 98 | self.tr("Delete service"), 99 | self.tr("Delete selected service?"), 100 | QMessageBox.StandardButton.Yes, 101 | QMessageBox.StandardButton.No, 102 | ) 103 | if res == QMessageBox.StandardButton.Yes: 104 | ds_info = self.lstServices.currentItem().data( 105 | Qt.ItemDataRole.UserRole 106 | ) 107 | dir_path = str(Path(ds_info.file_path).parent) 108 | shutil.rmtree(dir_path, True) 109 | self.feel_list() 110 | self.ds_model.resetModel() 111 | 112 | def on_copy(self): 113 | self.ds_model.sort(DSManagerModel.COLUMN_GROUP_DS) 114 | 115 | select_data_sources_dialog = QDialog(self) 116 | select_data_sources_dialog.resize(400, 400) 117 | select_data_sources_dialog.setWindowTitle( 118 | self.tr("Choose source service") 119 | ) 120 | layout = QVBoxLayout(select_data_sources_dialog) 121 | select_data_sources_dialog.setLayout(layout) 122 | 123 | list_view = QTreeView(self) 124 | layout.addWidget(list_view) 125 | list_view.setModel(self.ds_model) 126 | # list_view.expandAll() 127 | list_view.setColumnHidden(DSManagerModel.COLUMN_VISIBILITY, True) 128 | list_view.setAlternatingRowColors(True) 129 | 130 | if hasattr(list_view.header(), "setResizeMode"): 131 | # Qt4 132 | list_view.header().setResizeMode( 133 | DSManagerModel.COLUMN_GROUP_DS, 134 | QHeaderView.ResizeMode.ResizeToContents, 135 | ) 136 | else: 137 | # Qt5 138 | list_view.header().setSectionResizeMode( 139 | DSManagerModel.COLUMN_GROUP_DS, 140 | QHeaderView.ResizeMode.ResizeToContents, 141 | ) 142 | 143 | list_view.clicked.connect( 144 | lambda index: select_data_sources_dialog.accept() 145 | if not self.ds_model.isGroup(index) 146 | and index.column() == DSManagerModel.COLUMN_GROUP_DS 147 | else None 148 | ) 149 | 150 | if select_data_sources_dialog.exec() == QDialog.DialogCode.Accepted: 151 | data_source = self.ds_model.data( 152 | list_view.currentIndex(), Qt.ItemDataRole.UserRole 153 | ) 154 | data_source.id += "_copy" 155 | edit_dialog = DsEditDialog() 156 | edit_dialog.setWindowTitle(self.tr("Create service from existing")) 157 | edit_dialog.fill_ds_info(data_source) 158 | if edit_dialog.exec() == QDialog.DialogCode.Accepted: 159 | self.feel_list() 160 | self.ds_model.resetModel() 161 | -------------------------------------------------------------------------------- /src/quick_map_services/groups_list.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | /*************************************************************************** 4 | QuickMapServices 5 | A QGIS plugin 6 | Collection of internet map services 7 | ------------------- 8 | begin : 2014-11-21 9 | git sha : $Format:%H$ 10 | copyright : (C) 2014 by NextGIS 11 | email : info@nextgis.com 12 | ***************************************************************************/ 13 | 14 | /*************************************************************************** 15 | * * 16 | * This program is free software; you can redistribute it and/or modify * 17 | * it under the terms of the GNU General Public License as published by * 18 | * the Free Software Foundation; either version 2 of the License, or * 19 | * (at your option) any later version. * 20 | * * 21 | ***************************************************************************/ 22 | """ 23 | 24 | import codecs 25 | import configparser 26 | import os 27 | 28 | from qgis.PyQt.QtGui import QIcon 29 | from qgis.PyQt.QtWidgets import QMenu 30 | 31 | from quick_map_services.core import utils 32 | from quick_map_services.core.logging import logger 33 | 34 | from . import extra_sources 35 | from .config_reader_helper import ConfigReaderHelper 36 | from .custom_translator import CustomTranslator 37 | from .group_info import GroupCategory, GroupInfo 38 | 39 | CURR_PATH = os.path.dirname(__file__) 40 | 41 | INTERNAL_GROUP_PATHS = [ 42 | os.path.join(CURR_PATH, extra_sources.GROUPS_DIR_NAME), 43 | ] 44 | CONTRIBUTE_GROUP_PATHS = [ 45 | os.path.join( 46 | extra_sources.CONTRIBUTE_DIR_PATH, extra_sources.GROUPS_DIR_NAME 47 | ), 48 | ] 49 | USER_GROUP_PATHS = [ 50 | os.path.join(extra_sources.USER_DIR_PATH, extra_sources.GROUPS_DIR_NAME), 51 | ] 52 | 53 | ALL_GROUP_PATHS = ( 54 | INTERNAL_GROUP_PATHS + CONTRIBUTE_GROUP_PATHS + USER_GROUP_PATHS 55 | ) 56 | 57 | ROOT_MAPPING = { 58 | INTERNAL_GROUP_PATHS[0]: GroupCategory.BASE, 59 | CONTRIBUTE_GROUP_PATHS[0]: GroupCategory.CONTRIB, 60 | USER_GROUP_PATHS[0]: GroupCategory.USER, 61 | } 62 | 63 | 64 | class GroupsList: 65 | def __init__(self, group_paths=ALL_GROUP_PATHS): 66 | self.translator = CustomTranslator() 67 | self.paths = group_paths 68 | self.groups = {} 69 | self._fill_groups_list() 70 | 71 | def _fill_groups_list(self): 72 | self.groups = {} 73 | for gr_path in self.paths: 74 | if gr_path in ROOT_MAPPING.keys(): 75 | category = ROOT_MAPPING[gr_path] 76 | else: 77 | category = GroupCategory.USER 78 | 79 | for root, dirs, files in os.walk(gr_path): 80 | for ini_file in [f for f in files if f.endswith(".ini")]: 81 | self._read_ini_file(root, ini_file, category) 82 | 83 | def _read_ini_file( 84 | self, root: str, ini_file_path: str, category: str 85 | ) -> None: 86 | """ 87 | Parse a group definition `.ini` file and register it in the internal group list. 88 | 89 | This method reads the provided `.ini` configuration file, extracts general and 90 | UI-related metadata such as group ID, alias, and icon path, and creates a 91 | corresponding `GroupInfo` object representing the group. 92 | 93 | :param root: The root directory path where the `.ini` file is located. 94 | :type root: str 95 | :param ini_file_path: The name of the `.ini` file to be parsed. 96 | :type ini_file_path: str 97 | :param category: The category of the group (e.g., BASE, CONTRIB, USER). 98 | :type category: str 99 | 100 | :return: None 101 | :rtype: None 102 | """ 103 | ini_full_path = os.path.join(root, ini_file_path) 104 | 105 | try: 106 | parser = configparser.ConfigParser() 107 | with codecs.open(ini_full_path, "r", "utf-8") as ini_file: 108 | if hasattr(parser, "read_file"): 109 | parser.read_file(ini_file) 110 | else: 111 | parser.readfp(ini_file) 112 | 113 | # Extract group metadata 114 | group_id = parser.get("general", "id") 115 | group_alias = parser.get("ui", "alias") 116 | icon_file = ConfigReaderHelper.try_read_config( 117 | parser, "ui", "icon" 118 | ) 119 | group_icon_path = ( 120 | os.path.join(root, icon_file) if icon_file else None 121 | ) 122 | 123 | # Read possible translations 124 | posible_trans = parser.items("ui") 125 | for key, val in posible_trans: 126 | if key == f"alias[{utils.locale()}]": 127 | self.translator.append(group_alias, val) 128 | break 129 | 130 | except Exception: 131 | logger.exception( 132 | f"Failed to parse group INI file: {ini_full_path}" 133 | ) 134 | return 135 | 136 | # Create QMenu and GroupInfo 137 | group_menu = QMenu(self.tr(group_alias)) 138 | group_menu.setIcon(QIcon(group_icon_path)) 139 | 140 | self.groups[group_id] = GroupInfo( 141 | group_id, 142 | group_alias, 143 | group_icon_path, 144 | ini_full_path, 145 | group_menu, 146 | category, 147 | ) 148 | 149 | def get_group_menu(self, group_id): 150 | if group_id in self.groups: 151 | return self.groups[group_id].menu 152 | else: 153 | info = GroupInfo(group_id=group_id, menu=QMenu(group_id)) 154 | self.groups[group_id] = info 155 | return info.menu 156 | 157 | # noinspection PyMethodMayBeStatic 158 | def tr(self, message): 159 | # noinspection PyTypeChecker,PyArgumentList,PyCallByClass 160 | return self.translator.translate("QuickMapServices", message) 161 | -------------------------------------------------------------------------------- /src/quick_map_services/core/logging.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import re 3 | from typing import Union 4 | 5 | from qgis.core import Qgis, QgsApplication 6 | 7 | from quick_map_services.core.compat import QGIS_3_42_2 8 | from quick_map_services.core.constants import PLUGIN_NAME 9 | from quick_map_services.core.settings import QmsSettings 10 | 11 | SUCCESS_LEVEL = logging.INFO + 1 12 | logging.addLevelName(SUCCESS_LEVEL, "SUCCESS") 13 | 14 | 15 | def map_logging_level_to_qgis(level: int) -> Qgis.MessageLevel: 16 | """Map Python logging level to QGIS message level. 17 | 18 | :param level: Logging level 19 | :type level: int 20 | :return: QGIS message level 21 | :rtype: Qgis.MessageLevel 22 | """ 23 | if level >= logging.ERROR: 24 | return Qgis.MessageLevel.Critical 25 | if level >= logging.WARNING: 26 | return Qgis.MessageLevel.Warning 27 | if level == SUCCESS_LEVEL: 28 | return Qgis.MessageLevel.Success 29 | if level >= logging.DEBUG: 30 | return Qgis.MessageLevel.Info 31 | 32 | return Qgis.MessageLevel.NoLevel 33 | 34 | 35 | def map_qgis_level_to_logging(level: Qgis.MessageLevel) -> int: 36 | """Map QGIS message level to Python logging level. 37 | 38 | :param level: QGIS message level 39 | :type level: Qgis.MessageLevel 40 | :return: Corresponding Python logging level 41 | :rtype: int 42 | """ 43 | if level == Qgis.MessageLevel.Critical: 44 | return logging.ERROR 45 | if level == Qgis.MessageLevel.Warning: 46 | return logging.WARNING 47 | if level == Qgis.MessageLevel.Success: 48 | return SUCCESS_LEVEL 49 | if level == Qgis.MessageLevel.Info: 50 | return logging.INFO 51 | 52 | return logging.NOTSET 53 | 54 | 55 | class QgisLogger(logging.Logger): 56 | """Custom logger for QuickMapServices plugin. 57 | 58 | Provides integration with QGIS message log and adds a 'success' level. 59 | 60 | :param name: Logger name 61 | :type name: str 62 | :param level: Logging level 63 | :type level: int 64 | """ 65 | 66 | def __init__(self, name: str, level: int = logging.NOTSET) -> None: 67 | """Initialize QgisLogger instance. 68 | 69 | :param name: Logger name 70 | :type name: str 71 | :param level: Logging level 72 | :type level: int 73 | """ 74 | super().__init__(name, level) 75 | 76 | def log( 77 | self, 78 | level: Union[int, Qgis.MessageLevel], 79 | msg: str, 80 | *args, # noqa: ANN002 81 | **kwargs, # noqa: ANN003 82 | ) -> None: 83 | """Log 'msg % args' with the integer severity 'level'. 84 | 85 | To pass exception information, use the keyword argument exc_info with 86 | a true value, e.g. 87 | 88 | logger.log(level, "We have a %s", "mysterious problem", exc_info=True) 89 | """ 90 | if isinstance(level, Qgis.MessageLevel): 91 | level = map_qgis_level_to_logging(level) 92 | 93 | super().log(level, msg, *args, **kwargs) 94 | 95 | def success(self, message: str, *args, **kwargs) -> None: # noqa: ANN002, ANN003 96 | """Log a message with SUCCESS level. 97 | 98 | :param message: Log message 99 | :type message: str 100 | """ 101 | if self.isEnabledFor(SUCCESS_LEVEL): 102 | self._log(SUCCESS_LEVEL, message, args, **kwargs) 103 | 104 | 105 | class QgisLoggerHandler(logging.Handler): 106 | """Logging handler that sends messages to QGIS message log. 107 | 108 | Formats and routes log records to QgsApplication.messageLog(). 109 | """ 110 | 111 | def emit(self, record: logging.LogRecord) -> None: 112 | """Emit a log record to QGIS message log. 113 | 114 | :param record: Log record 115 | :type record: logging.LogRecord 116 | """ 117 | level = map_logging_level_to_qgis(record.levelno) 118 | message = self.format(record) 119 | message_log = QgsApplication.messageLog() 120 | if record.levelno == logging.DEBUG: 121 | message = f"[DEBUG] {message}" 122 | assert message_log is not None 123 | 124 | message_log.logMessage(self._process_html(message), record.name, level) 125 | 126 | def _process_html(self, message: str) -> str: 127 | """Process message for HTML compatibility in QGIS log. 128 | 129 | :param message: Log message 130 | :type message: str 131 | :return: Processed message 132 | :rtype: str 133 | """ 134 | message = message.replace(" ", "\u00a0") 135 | 136 | if Qgis.versionInt() < QGIS_3_42_2: 137 | return message 138 | 139 | # https://github.com/qgis/QGIS/issues/45834 140 | for tag in ("i", "b"): 141 | message = re.sub( 142 | rf"<{tag}\b[^>]*?>", "", message, flags=re.IGNORECASE 143 | ) 144 | message = re.sub(rf"", "", message, flags=re.IGNORECASE) 145 | 146 | return message 147 | 148 | 149 | def load_logger() -> QgisLogger: 150 | """Create and configure QgisLogger instance. 151 | 152 | Temporarily sets QgisLogger as the logger class, then restores the original. 153 | 154 | :return: Configured QgisLogger instance 155 | :rtype: QgisLogger 156 | """ 157 | original_logger_class = logging.getLoggerClass() 158 | logging.setLoggerClass(QgisLogger) 159 | try: 160 | logger = logging.getLogger(PLUGIN_NAME) 161 | finally: 162 | logging.setLoggerClass(original_logger_class) 163 | 164 | logger.propagate = False 165 | 166 | handler = QgisLoggerHandler() 167 | logger.addHandler(handler) 168 | 169 | is_debug_logs_enabled = QmsSettings().is_debug_logs_enabled 170 | logger.setLevel(logging.DEBUG if is_debug_logs_enabled else logging.INFO) 171 | if is_debug_logs_enabled: 172 | logger.warning("Debug messages are enabled") 173 | 174 | return logger # type: ignore[return-value] 175 | 176 | 177 | def update_logging_level() -> None: 178 | """Update logging level based on QuickMapServices settings.""" 179 | is_debug_logs_enabled = QmsSettings().is_debug_logs_enabled 180 | logger.setLevel(logging.DEBUG if is_debug_logs_enabled else logging.INFO) 181 | 182 | 183 | def unload_logger() -> None: 184 | """Remove all handlers and reset logger.""" 185 | logger = logging.getLogger(PLUGIN_NAME) 186 | 187 | handlers = logger.handlers.copy() 188 | for handler in handlers: 189 | logger.removeHandler(handler) 190 | handler.close() 191 | 192 | logger.propagate = True 193 | 194 | 195 | logger = load_logger() 196 | -------------------------------------------------------------------------------- /src/quick_map_services/extra_sources.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | /*************************************************************************** 4 | QuickMapServices 5 | A QGIS plugin 6 | Collection of internet map services 7 | ------------------- 8 | begin : 2014-11-21 9 | git sha : $Format:%H$ 10 | copyright : (C) 2014 by NextGIS 11 | email : info@nextgis.com 12 | ***************************************************************************/ 13 | 14 | /*************************************************************************** 15 | * * 16 | * This program is free software; you can redistribute it and/or modify * 17 | * it under the terms of the GNU General Public License as published by * 18 | * the Free Software Foundation; either version 2 of the License, or * 19 | * (at your option) any later version. * 20 | * * 21 | ***************************************************************************/ 22 | """ 23 | 24 | import os 25 | import shutil 26 | import tempfile 27 | from zipfile import ZipFile 28 | 29 | from qgis.core import QgsApplication, QgsNetworkAccessManager 30 | from qgis.PyQt.QtCore import QEventLoop, QFile, QUrl 31 | from qgis.PyQt.QtNetwork import QNetworkReply, QNetworkRequest 32 | 33 | from quick_map_services.core.compat import OpenModeFlag 34 | from quick_map_services.core.constants import PLUGIN_NAME 35 | 36 | LOCAL_SETTINGS_PATH = os.path.dirname( 37 | QgsApplication.qgisUserDatabaseFilePath() 38 | ) 39 | PLUGIN_SETTINGS_PATH = os.path.join(LOCAL_SETTINGS_PATH, PLUGIN_NAME) 40 | 41 | CONTRIBUTE_DIR_PATH = os.path.join(PLUGIN_SETTINGS_PATH, "Contribute") 42 | USER_DIR_PATH = os.path.join(PLUGIN_SETTINGS_PATH, "User") 43 | 44 | DATA_SOURCES_DIR_NAME = "data_sources" 45 | GROUPS_DIR_NAME = "groups" 46 | 47 | # CONTRIBUTE_REPO_URL = 'https://api.github.com/repos/nextgis/quickmapservices_contrib' 48 | CONTRIBUTE_ZIP_DIRECT_URL = "https://github.com/nextgis/quickmapservices_contrib/archive/refs/tags/v1.23.zip" 49 | 50 | 51 | class ExtraSources: 52 | __replies = [] 53 | 54 | @classmethod 55 | def check_extra_dirs(cls): 56 | if not os.path.exists(PLUGIN_SETTINGS_PATH): 57 | os.mkdir(PLUGIN_SETTINGS_PATH) 58 | if not os.path.exists(CONTRIBUTE_DIR_PATH): 59 | os.mkdir(CONTRIBUTE_DIR_PATH) 60 | if not os.path.exists(USER_DIR_PATH): 61 | os.mkdir(USER_DIR_PATH) 62 | 63 | for base_folder in (CONTRIBUTE_DIR_PATH, USER_DIR_PATH): 64 | ds_folder = os.path.join(base_folder, DATA_SOURCES_DIR_NAME) 65 | if not os.path.exists(ds_folder): 66 | os.mkdir(ds_folder) 67 | groups_folder = os.path.join(base_folder, GROUPS_DIR_NAME) 68 | if not os.path.exists(groups_folder): 69 | os.mkdir(groups_folder) 70 | 71 | def load_contrib_pack(self): 72 | self.check_extra_dirs() 73 | 74 | # get info 75 | # latest_release_info = self._get_latest_release_info() 76 | # name = latest_release_info['name'] 77 | # zip_url = latest_release_info['zipball_url'] 78 | 79 | # create temp dir 80 | tmp_dir = tempfile.mkdtemp() 81 | 82 | # download zip file 83 | zip_file_path = os.path.join(tmp_dir, "contrib.zip") 84 | # self._download_file(zip_url, zip_file_path) 85 | self._download_file(CONTRIBUTE_ZIP_DIRECT_URL, zip_file_path) 86 | 87 | # extract zip to tmp dir 88 | tmp_extract_dir = os.path.join(tmp_dir, "contrib") 89 | self._extract_zip(zip_file_path, tmp_extract_dir) 90 | 91 | # first dir - our content 92 | src_dir_name = os.listdir(tmp_extract_dir)[0] 93 | src_dir = os.path.join(tmp_extract_dir, src_dir_name) 94 | 95 | # clear dst dir and copy 96 | shutil.rmtree(CONTRIBUTE_DIR_PATH, ignore_errors=True) 97 | shutil.copytree(src_dir, CONTRIBUTE_DIR_PATH) 98 | 99 | # remove tmp dir 100 | shutil.rmtree(tmp_dir, ignore_errors=True) 101 | 102 | # def _get_releases_info(self): 103 | # response = urlopen('%s/%s' % (CONTRIBUTE_REPO_URL, 'releases')) 104 | # releases_info = json.loads(response.read().decode('utf-8')) 105 | # return releases_info 106 | 107 | # def _get_latest_release_info(self): 108 | # url = '%s/%s/%s' % (CONTRIBUTE_REPO_URL, 'releases', 'latest') 109 | # reply = self.__sync_request(url) 110 | # latest_release_info = json.loads(reply.data().decode("utf-8")) 111 | # return latest_release_info 112 | 113 | def _download_file(self, url, out_path): 114 | reply = self.__sync_request(url) 115 | local_file = QFile(out_path) 116 | local_file.open(OpenModeFlag) 117 | local_file.write(reply) 118 | local_file.close() 119 | 120 | def _extract_zip(self, zip_path, out_path): 121 | zf = ZipFile(zip_path) 122 | zf.extractall(out_path) 123 | 124 | def __sync_request(self, url): 125 | _url = QUrl(url) 126 | _request = QNetworkRequest(_url) 127 | self.__replies.append(_request) 128 | 129 | QgsNetworkAccessManager.instance().sslErrors.connect( 130 | self.__supress_ssl_errors 131 | ) 132 | 133 | _reply = QgsNetworkAccessManager.instance().get(_request) 134 | 135 | # wait 136 | loop = QEventLoop() 137 | _reply.finished.connect(loop.quit) 138 | loop.exec() 139 | _reply.finished.disconnect(loop.quit) 140 | QgsNetworkAccessManager.instance().sslErrors.disconnect( 141 | self.__supress_ssl_errors 142 | ) 143 | loop = None 144 | 145 | error = _reply.error() 146 | if error != QNetworkReply.NetworkError.NoError: 147 | raise Exception(error) 148 | 149 | result_code = _reply.attribute( 150 | QNetworkRequest.Attribute.HttpStatusCodeAttribute 151 | ) 152 | 153 | result = _reply.readAll() 154 | self.__replies.append(_reply) 155 | _reply.deleteLater() 156 | 157 | if result_code in [301, 302, 307]: 158 | redirect_url = _reply.attribute( 159 | QNetworkRequest.Attribute.RedirectionTargetAttribute 160 | ) 161 | return self.__sync_request(redirect_url) 162 | else: 163 | return result 164 | 165 | def __supress_ssl_errors(self, reply, errors): 166 | reply.ignoreSslErrors() 167 | -------------------------------------------------------------------------------- /src/quick_map_services/gui/user_groups_box.py: -------------------------------------------------------------------------------- 1 | import shutil 2 | from pathlib import Path 3 | from typing import Optional 4 | 5 | from qgis.core import QgsApplication 6 | from qgis.PyQt import uic 7 | from qgis.PyQt.QtCore import Qt 8 | from qgis.PyQt.QtGui import QIcon 9 | from qgis.PyQt.QtWidgets import ( 10 | QAbstractItemView, 11 | QDialog, 12 | QGroupBox, 13 | QHeaderView, 14 | QListWidgetItem, 15 | QMessageBox, 16 | QTableView, 17 | QVBoxLayout, 18 | QWidget, 19 | ) 20 | 21 | from quick_map_services.data_sources_model import DSManagerModel 22 | from quick_map_services.group_edit_dialog import GroupEditDialog 23 | from quick_map_services.groups_list import USER_GROUP_PATHS, GroupsList 24 | 25 | FORM_CLASS, _ = uic.loadUiType( 26 | str(Path(__file__).parent / "user_groups_box.ui") 27 | ) 28 | 29 | 30 | class UserGroupsBox(QGroupBox, FORM_CLASS): 31 | """ 32 | Widget for managing user-defined service groups in QuickMapServices. 33 | """ 34 | 35 | def __init__(self, parent: Optional[QWidget] = None) -> None: 36 | """ 37 | Initialize the user groups management box. 38 | 39 | :param parent: Optional parent widget. 40 | :type parent: Optional[QWidget] 41 | """ 42 | super(UserGroupsBox, self).__init__(parent) 43 | self.setupUi(self) 44 | 45 | self.feel_list() 46 | 47 | self.lstGroups.currentItemChanged.connect(self.on_sel_changed) 48 | self.lstGroups.itemDoubleClicked.connect(self.on_edit) 49 | self.btnEdit.clicked.connect(self.on_edit) 50 | self.btnAdd.clicked.connect(self.on_add) 51 | self.btnDelete.clicked.connect(self.on_delete) 52 | self.btnCopy.clicked.connect(self.on_copy) 53 | 54 | self.btnAdd.setIcon(QgsApplication.getThemeIcon("symbologyAdd.svg")) 55 | self.btnEdit.setIcon(QgsApplication.getThemeIcon("symbologyEdit.svg")) 56 | self.btnDelete.setIcon( 57 | QgsApplication.getThemeIcon("symbologyRemove.svg") 58 | ) 59 | self.btnCopy.setIcon( 60 | QgsApplication.getThemeIcon("mActionEditCopy.svg") 61 | ) 62 | 63 | self.ds_model = DSManagerModel() 64 | 65 | def feel_list(self): 66 | self.lstGroups.clear() 67 | ds_groups = GroupsList(USER_GROUP_PATHS) 68 | for ds_group in ds_groups.groups.values(): 69 | item = QListWidgetItem( 70 | QIcon(ds_group.icon), self.tr(ds_group.alias) 71 | ) 72 | item.setData(Qt.ItemDataRole.UserRole, ds_group) 73 | self.lstGroups.addItem(item) 74 | 75 | def on_sel_changed(self, curr, prev): 76 | has_sel = curr is not None 77 | self.btnEdit.setEnabled(has_sel) 78 | self.btnDelete.setEnabled(has_sel) 79 | 80 | def on_add(self): 81 | edit_dialog = GroupEditDialog() 82 | edit_dialog.setWindowTitle(self.tr("Create group")) 83 | if edit_dialog.exec() == QDialog.DialogCode.Accepted: 84 | self.feel_list() 85 | self.ds_model.resetModel() 86 | 87 | def on_edit(self): 88 | item = self.lstGroups.currentItem().data(Qt.ItemDataRole.UserRole) 89 | edit_dialog = GroupEditDialog() 90 | edit_dialog.setWindowTitle(self.tr("Edit group")) 91 | edit_dialog.set_group_info(item) 92 | if edit_dialog.exec() == QDialog.DialogCode.Accepted: 93 | self.feel_list() 94 | self.ds_model.resetModel() 95 | 96 | def on_delete(self) -> None: 97 | """ 98 | Delete the currently selected user group after confirmation. 99 | """ 100 | res = QMessageBox.question( 101 | None, 102 | self.tr("Delete group"), 103 | self.tr("Delete selected group?"), 104 | QMessageBox.StandardButton.Yes, 105 | QMessageBox.StandardButton.No, 106 | ) 107 | if res == QMessageBox.StandardButton.Yes: 108 | group_info = self.lstGroups.currentItem().data( 109 | Qt.ItemDataRole.UserRole 110 | ) 111 | dir_path = str(Path(group_info.file_path).parent) 112 | shutil.rmtree(dir_path, True) 113 | self.feel_list() 114 | self.ds_model.resetModel() 115 | 116 | def on_copy(self): 117 | select_group_dialog = QDialog(self) 118 | select_group_dialog.resize(300, 400) 119 | select_group_dialog.setWindowTitle(self.tr("Choose source group")) 120 | layout = QVBoxLayout(select_group_dialog) 121 | select_group_dialog.setLayout(layout) 122 | 123 | groups_list_view = QTableView(self) 124 | layout.addWidget(groups_list_view) 125 | groups_list_view.setModel(self.ds_model) 126 | groups_list_view.setColumnHidden( 127 | DSManagerModel.COLUMN_VISIBILITY, True 128 | ) 129 | groups_list_view.setSelectionMode( 130 | QAbstractItemView.SelectionMode.NoSelection 131 | ) 132 | groups_list_view.setAlternatingRowColors(True) 133 | groups_list_view.setShowGrid(False) 134 | if hasattr(groups_list_view.horizontalHeader(), "setResizeMode"): 135 | # Qt4 136 | groups_list_view.horizontalHeader().setResizeMode( 137 | DSManagerModel.COLUMN_GROUP_DS, QHeaderView.ResizeMode.Stretch 138 | ) 139 | groups_list_view.verticalHeader().setResizeMode( 140 | QHeaderView.ResizeMode.ResizeToContents 141 | ) 142 | else: 143 | # Qt5 144 | groups_list_view.horizontalHeader().setSectionResizeMode( 145 | DSManagerModel.COLUMN_GROUP_DS, QHeaderView.ResizeMode.Stretch 146 | ) 147 | groups_list_view.verticalHeader().setSectionResizeMode( 148 | QHeaderView.ResizeMode.ResizeToContents 149 | ) 150 | 151 | groups_list_view.verticalHeader().hide() 152 | groups_list_view.clicked.connect( 153 | lambda index: select_group_dialog.accept() 154 | if self.ds_model.isGroup(index) 155 | and index.column() == DSManagerModel.COLUMN_GROUP_DS 156 | else None 157 | ) 158 | 159 | if select_group_dialog.exec() == QDialog.DialogCode.Accepted: 160 | group_info = self.ds_model.data( 161 | groups_list_view.currentIndex(), Qt.ItemDataRole.UserRole 162 | ) 163 | group_info.id += "_copy" 164 | edit_dialog = GroupEditDialog() 165 | edit_dialog.setWindowTitle(self.tr("Create group from existing")) 166 | edit_dialog.fill_group_info(group_info) 167 | if edit_dialog.exec() == QDialog.DialogCode.Accepted: 168 | self.feel_list() 169 | self.ds_model.resetModel() 170 | -------------------------------------------------------------------------------- /src/quick_map_services/notifier/message_bar_notifier.py: -------------------------------------------------------------------------------- 1 | import re 2 | import uuid 3 | from typing import TYPE_CHECKING, List, Optional 4 | 5 | from qgis.core import Qgis 6 | from qgis.PyQt.QtCore import QObject, QUrl 7 | from qgis.PyQt.QtGui import QDesktopServices 8 | from qgis.PyQt.QtWidgets import QMessageBox, QPushButton, QWidget 9 | from qgis.utils import iface 10 | 11 | from quick_map_services.core.constants import PLUGIN_NAME 12 | from quick_map_services.core.exceptions import QmsError, QmsWarning 13 | from quick_map_services.core.logging import logger 14 | from quick_map_services.core.utils import utm_tags 15 | from quick_map_services.notifier.notifier_interface import NotifierInterface 16 | from quick_map_services.quick_map_services_interface import ( 17 | QuickMapServicesInterface, 18 | ) 19 | 20 | if TYPE_CHECKING: 21 | from qgis.gui import QgisInterface 22 | 23 | assert isinstance(iface, QgisInterface) 24 | 25 | 26 | def let_us_know() -> None: 27 | plugin = QuickMapServicesInterface.instance() 28 | tracker_url = plugin.metadata.get("general", "tracker") 29 | 30 | if "github" in tracker_url: 31 | QDesktopServices.openUrl(QUrl(tracker_url)) 32 | else: 33 | utm = utm_tags("error") 34 | QDesktopServices.openUrl(QUrl(f"{tracker_url}/?{utm}")) 35 | 36 | 37 | def open_logs() -> None: 38 | iface.openMessageLog() 39 | 40 | 41 | class MessageBarNotifier(NotifierInterface): 42 | """Notifier implementation for displaying messages and exceptions in QGIS. 43 | 44 | Provides methods to show messages and exceptions using QGIS message bar. 45 | """ 46 | 47 | def __init__(self, parent: Optional[QObject]) -> None: 48 | """Initialize MessageBarNotifier with an optional parent QObject. 49 | 50 | :param parent: The parent QObject for this notifier. 51 | """ 52 | super().__init__(parent) 53 | 54 | def __del__(self) -> None: 55 | """Dismiss all messages on object deletion.""" 56 | self.dismiss_all() 57 | 58 | def display_message( 59 | self, 60 | message: str, 61 | *, 62 | level: Qgis.MessageLevel = Qgis.MessageLevel.Info, 63 | widgets: Optional[List[QWidget]] = None, 64 | **kwargs, # noqa: ANN003, ARG002 65 | ) -> str: 66 | """Display a message to the user via the QGIS message bar. 67 | 68 | :param message: The message to display. 69 | :param level: The message level as Qgis.MessageLevel. 70 | :param widgets: Custom widgets for message. 71 | :return: An identifier for the displayed message. 72 | """ 73 | custom_widgets = widgets if widgets else [] 74 | 75 | message_bar = iface.messageBar() 76 | widget = message_bar.createMessage(PLUGIN_NAME, message) 77 | 78 | for custom_widget in custom_widgets: 79 | custom_widget.setParent(widget) 80 | widget.layout().addWidget(custom_widget) 81 | 82 | item = message_bar.pushWidget(widget, level) 83 | item.setObjectName("QmsMessageBarItem") 84 | message_id = str(uuid.uuid4()) 85 | item.setProperty("QmsMessageId", message_id) 86 | 87 | logger.log(level, message) 88 | 89 | return message_id 90 | 91 | def display_exception(self, error: Exception) -> str: 92 | """Display an exception as an error message to the user. 93 | 94 | :param error: The exception to display. 95 | :return: An identifier for the displayed message. 96 | """ 97 | if not isinstance(error, (QmsError, QmsWarning)): 98 | old_error = error 99 | error = ( 100 | QmsError() if not isinstance(error, Warning) else QmsWarning() 101 | ) 102 | error.__cause__ = old_error 103 | del old_error 104 | 105 | message = error.user_message.rstrip(".") + "." 106 | 107 | message_bar = iface.messageBar() 108 | widget = message_bar.createMessage(PLUGIN_NAME, message) 109 | 110 | if not isinstance(error, Warning): 111 | self._add_error_buttons(error, widget) 112 | 113 | level = ( 114 | Qgis.MessageLevel.Critical 115 | if not isinstance(error, QmsWarning) 116 | else Qgis.MessageLevel.Warning 117 | ) 118 | 119 | item = message_bar.pushWidget(widget, level) 120 | item.setObjectName("QmsMessageBarItem") 121 | item.setProperty("QmsMessageId", error.error_id) 122 | 123 | if level == Qgis.MessageLevel.Critical: 124 | logger.exception(error.log_message, exc_info=error) 125 | else: 126 | logger.warning(error.user_message) 127 | 128 | return error.error_id 129 | 130 | def dismiss_message(self, message_id: str) -> None: 131 | """Dismiss a specific message by its identifier. 132 | 133 | :param message_id: The identifier of the message to dismiss. 134 | """ 135 | for notification in iface.messageBar().items(): 136 | if ( 137 | notification.objectName() != "QmsMessageBarItem" 138 | or notification.property("QmsMessageId") != message_id 139 | ): 140 | continue 141 | iface.messageBar().popWidget(notification) 142 | 143 | def dismiss_all(self) -> None: 144 | """Dismiss all currently displayed messages.""" 145 | for notification in iface.messageBar().items(): 146 | if notification.objectName() != "QmsMessageBarItem": 147 | continue 148 | iface.messageBar().popWidget(notification) 149 | 150 | def _add_error_buttons(self, error: QmsError, widget: QWidget) -> None: 151 | def show_details() -> None: 152 | user_message = error.user_message.rstrip(".") 153 | user_message = re.sub( 154 | r"]*?>", "", user_message, flags=re.IGNORECASE 155 | ) 156 | QMessageBox.information( 157 | iface.mainWindow(), user_message, error.detail or "" 158 | ) 159 | 160 | if error.try_again is not None: 161 | 162 | def try_again() -> None: 163 | error.try_again() 164 | iface.messageBar().popWidget(widget) 165 | 166 | button = QPushButton(self.tr("Try again")) 167 | button.pressed.connect(try_again) 168 | widget.layout().addWidget(button) 169 | 170 | for action_name, action_callback in error.actions: 171 | button = QPushButton(action_name) 172 | button.pressed.connect(action_callback) 173 | widget.layout().addWidget(button) 174 | 175 | if error.detail is not None: 176 | button = QPushButton(self.tr("Details")) 177 | button.pressed.connect(show_details) 178 | widget.layout().addWidget(button) 179 | else: 180 | button = QPushButton(self.tr("Open logs")) 181 | button.pressed.connect(open_logs) 182 | widget.layout().addWidget(button) 183 | 184 | if type(error) is QmsError: 185 | button = QPushButton(self.tr("Let us know")) 186 | button.pressed.connect(let_us_know) 187 | widget.layout().addWidget(button) 188 | -------------------------------------------------------------------------------- /src/quick_map_services/ds_edit_dialog.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | Dialog 4 | 5 | 6 | 7 | 0 8 | 0 9 | 438 10 | 216 11 | 12 | 13 | 14 | Dialog 15 | 16 | 17 | 18 | 19 | 20 | 0 21 | 22 | 23 | 24 | General 25 | 26 | 27 | 28 | 29 | 30 | ID 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | Alias 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | Icon 51 | 52 | 53 | 54 | 55 | 56 | 57 | Group 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | Type 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 30 78 | 79 | 80 | 81 | 82 | 83 | 24 84 | 24 85 | 86 | 87 | 88 | Icon 89 | 90 | 91 | true 92 | 93 | 94 | 95 | 96 | 97 | 98 | Choose 99 | 100 | 101 | 102 | 103 | 104 | 105 | Qt::Horizontal 106 | 107 | 108 | 109 | 40 110 | 20 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | License 122 | 123 | 124 | 125 | QFormLayout::ExpandingFieldsGrow 126 | 127 | 128 | 129 | 130 | License 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | License link 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | Copyright 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | Copyright link 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | 170 | Terms of use 171 | 172 | 173 | 174 | 175 | 176 | 177 | 178 | 179 | 180 | 181 | 182 | 183 | 184 | Qt::Horizontal 185 | 186 | 187 | QDialogButtonBox::Cancel|QDialogButtonBox::Ok 188 | 189 | 190 | 191 | 192 | 193 | 194 | 195 | 196 | buttonBox 197 | accepted() 198 | Dialog 199 | accept() 200 | 201 | 202 | 248 203 | 254 204 | 205 | 206 | 157 207 | 274 208 | 209 | 210 | 211 | 212 | buttonBox 213 | rejected() 214 | Dialog 215 | reject() 216 | 217 | 218 | 316 219 | 260 220 | 221 | 222 | 286 223 | 274 224 | 225 | 226 | 227 | 228 | 229 | -------------------------------------------------------------------------------- /src/quick_map_services/help/source/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # QuickMapServices documentation build configuration file, created by 4 | # sphinx-quickstart on Sun Feb 12 17:11:03 2012. 5 | # 6 | # This file is execfile()d with the current directory set to its containing dir. 7 | # 8 | # Note that not all possible configuration values are present in this 9 | # autogenerated file. 10 | # 11 | # All configuration values have a default; values that are commented out 12 | # serve to show the default. 13 | 14 | import os 15 | import sys 16 | 17 | # If extensions (or modules to document with autodoc) are in another directory, 18 | # add these directories to sys.path here. If the directory is relative to the 19 | # documentation root, use os.path.abspath to make it absolute, like shown here. 20 | # sys.path.insert(0, os.path.abspath('.')) 21 | 22 | # -- General configuration ----------------------------------------------------- 23 | 24 | # If your documentation needs a minimal Sphinx version, state it here. 25 | # needs_sphinx = '1.0' 26 | 27 | # Add any Sphinx extension module names here, as strings. They can be extensions 28 | # coming with Sphinx (named 'sphinx.ext.*') or your custom ones. 29 | extensions = ["sphinx.ext.todo", "sphinx.ext.pngmath", "sphinx.ext.viewcode"] 30 | 31 | # Add any paths that contain templates here, relative to this directory. 32 | templates_path = ["_templates"] 33 | 34 | # The suffix of source filenames. 35 | source_suffix = ".rst" 36 | 37 | # The encoding of source files. 38 | # source_encoding = 'utf-8-sig' 39 | 40 | # The master toctree document. 41 | master_doc = "index" 42 | 43 | # General information about the project. 44 | project = "QuickMapServices" 45 | copyright = "2013, NextGIS" 46 | 47 | # The version info for the project you're documenting, acts as replacement for 48 | # |version| and |release|, also used in various other places throughout the 49 | # built documents. 50 | # 51 | # The short X.Y version. 52 | version = "0.1" 53 | # The full version, including alpha/beta/rc tags. 54 | release = "0.1" 55 | 56 | # The language for content autogenerated by Sphinx. Refer to documentation 57 | # for a list of supported languages. 58 | # language = None 59 | 60 | # There are two options for replacing |today|: either, you set today to some 61 | # non-false value, then it is used: 62 | # today = '' 63 | # Else, today_fmt is used as the format for a strftime call. 64 | # today_fmt = '%B %d, %Y' 65 | 66 | # List of patterns, relative to source directory, that match files and 67 | # directories to ignore when looking for source files. 68 | exclude_patterns = [] 69 | 70 | # The reST default role (used for this markup: `text`) to use for all documents. 71 | # default_role = None 72 | 73 | # If true, '()' will be appended to :func: etc. cross-reference text. 74 | # add_function_parentheses = True 75 | 76 | # If true, the current module name will be prepended to all description 77 | # unit titles (such as .. function::). 78 | # add_TemplateModuleNames = True 79 | 80 | # If true, sectionauthor and moduleauthor directives will be shown in the 81 | # output. They are ignored by default. 82 | # show_authors = False 83 | 84 | # The name of the Pygments (syntax highlighting) style to use. 85 | pygments_style = "sphinx" 86 | 87 | # A list of ignored prefixes for module index sorting. 88 | # modindex_common_prefix = [] 89 | 90 | 91 | # -- Options for HTML output --------------------------------------------------- 92 | 93 | # The theme to use for HTML and HTML Help pages. See the documentation for 94 | # a list of builtin themes. 95 | html_theme = "default" 96 | 97 | # Theme options are theme-specific and customize the look and feel of a theme 98 | # further. For a list of options available for each theme, see the 99 | # documentation. 100 | # html_theme_options = {} 101 | 102 | # Add any paths that contain custom themes here, relative to this directory. 103 | # html_theme_path = [] 104 | 105 | # The name for this set of Sphinx documents. If None, it defaults to 106 | # " v documentation". 107 | # html_title = None 108 | 109 | # A shorter title for the navigation bar. Default is the same as html_title. 110 | # html_short_title = None 111 | 112 | # The name of an image file (relative to this directory) to place at the top 113 | # of the sidebar. 114 | # html_logo = None 115 | 116 | # The name of an image file (within the static path) to use as favicon of the 117 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 118 | # pixels large. 119 | # html_favicon = None 120 | 121 | # Add any paths that contain custom static files (such as style sheets) here, 122 | # relative to this directory. They are copied after the builtin static files, 123 | # so a file named "default.css" will overwrite the builtin "default.css". 124 | html_static_path = ["_static"] 125 | 126 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 127 | # using the given strftime format. 128 | # html_last_updated_fmt = '%b %d, %Y' 129 | 130 | # If true, SmartyPants will be used to convert quotes and dashes to 131 | # typographically correct entities. 132 | # html_use_smartypants = True 133 | 134 | # Custom sidebar templates, maps document names to template names. 135 | # html_sidebars = {} 136 | 137 | # Additional templates that should be rendered to pages, maps page names to 138 | # template names. 139 | # html_additional_pages = {} 140 | 141 | # If false, no module index is generated. 142 | # html_domain_indices = True 143 | 144 | # If false, no index is generated. 145 | # html_use_index = True 146 | 147 | # If true, the index is split into individual pages for each letter. 148 | # html_split_index = False 149 | 150 | # If true, links to the reST sources are added to the pages. 151 | # html_show_sourcelink = True 152 | 153 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 154 | # html_show_sphinx = True 155 | 156 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 157 | # html_show_copyright = True 158 | 159 | # If true, an OpenSearch description file will be output, and all pages will 160 | # contain a tag referring to it. The value of this option must be the 161 | # base URL from which the finished HTML is served. 162 | # html_use_opensearch = '' 163 | 164 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 165 | # html_file_suffix = None 166 | 167 | # Output file base name for HTML help builder. 168 | htmlhelp_basename = "TemplateClassdoc" 169 | 170 | 171 | # -- Options for LaTeX output -------------------------------------------------- 172 | 173 | # The paper size ('letter' or 'a4'). 174 | # latex_paper_size = 'letter' 175 | 176 | # The font size ('10pt', '11pt' or '12pt'). 177 | # latex_font_size = '10pt' 178 | 179 | # Grouping the document tree into LaTeX files. List of tuples 180 | # (source start file, target name, title, author, documentclass [howto/manual]). 181 | latex_documents = [ 182 | ( 183 | "index", 184 | "QuickMapServices.tex", 185 | "QuickMapServices Documentation", 186 | "NextGIS", 187 | "manual", 188 | ), 189 | ] 190 | 191 | # The name of an image file (relative to this directory) to place at the top of 192 | # the title page. 193 | # latex_logo = None 194 | 195 | # For "manual" documents, if this is true, then toplevel headings are parts, 196 | # not chapters. 197 | # latex_use_parts = False 198 | 199 | # If true, show page references after internal links. 200 | # latex_show_pagerefs = False 201 | 202 | # If true, show URL addresses after external links. 203 | # latex_show_urls = False 204 | 205 | # Additional stuff for the LaTeX preamble. 206 | # latex_preamble = '' 207 | 208 | # Documents to append as an appendix to all manuals. 209 | # latex_appendices = [] 210 | 211 | # If false, no module index is generated. 212 | # latex_domain_indices = True 213 | 214 | 215 | # -- Options for manual page output -------------------------------------------- 216 | 217 | # One entry per manual page. List of tuples 218 | # (source start file, name, description, authors, manual section). 219 | man_pages = [ 220 | ( 221 | "index", 222 | "TemplateClass", 223 | "QuickMapServices Documentation", 224 | ["NextGIS"], 225 | 1, 226 | ) 227 | ] 228 | -------------------------------------------------------------------------------- /src/quick_map_services/gui/qms_settings_page.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from typing import List, Optional 3 | 4 | from qgis.core import QgsApplication 5 | from qgis.gui import ( 6 | QgsOptionsPageWidget, 7 | QgsOptionsWidgetFactory, 8 | ) 9 | from qgis.PyQt import uic 10 | from qgis.PyQt.QtCore import Qt, pyqtSlot 11 | from qgis.PyQt.QtGui import QIcon 12 | from qgis.PyQt.QtWidgets import ( 13 | QHeaderView, 14 | QLabel, 15 | QMessageBox, 16 | QVBoxLayout, 17 | QWidget, 18 | ) 19 | 20 | from quick_map_services.core.constants import COMPANY_NAME, PLUGIN_NAME 21 | from quick_map_services.core.exceptions import QmsUiLoadError 22 | from quick_map_services.core.logging import logger, update_logging_level 23 | from quick_map_services.core.settings import QmsSettings 24 | from quick_map_services.data_sources_model import DSManagerModel 25 | from quick_map_services.extra_sources import ExtraSources 26 | from quick_map_services.gui.user_groups_box import UserGroupsBox 27 | from quick_map_services.gui.user_services_box import UserServicesBox 28 | 29 | 30 | class QmsSettingsPage(QgsOptionsPageWidget): 31 | """ 32 | QMS plugin settings page integrated into QGIS Options dialog. 33 | 34 | Loads the original .ui-based settings interface and connects 35 | QMS settings, data source model, and extra services actions. 36 | """ 37 | 38 | __ds_model: DSManagerModel 39 | 40 | def __init__(self, parent: Optional[QWidget] = None) -> None: 41 | """Initialize the settings page widget. 42 | 43 | :param parent: Optional parent widget. 44 | :type parent: Optional[QWidget] 45 | """ 46 | super().__init__(parent) 47 | 48 | self.__ds_model = DSManagerModel() 49 | 50 | self.__load_ui() 51 | self.__load_settings() 52 | 53 | def apply(self) -> None: 54 | """ 55 | Save current settings when user confirms changes. 56 | 57 | :return: None 58 | :rtype: None 59 | """ 60 | settings = QmsSettings() 61 | 62 | settings.enable_otf_3857 = self.__widget.chkEnableOTF3857.isChecked() 63 | self.__save_other(settings) 64 | 65 | self.__ds_model.saveSettings() 66 | 67 | def cancel(self) -> None: 68 | """Cancel changes made in the settings page.""" 69 | 70 | def __load_ui(self) -> None: 71 | """Load .ui file and prepare layout.""" 72 | widget: Optional[QWidget] = None 73 | try: 74 | widget = uic.loadUi( 75 | str(Path(__file__).parent / "qms_settings_page_base.ui") 76 | ) 77 | except Exception as error: 78 | raise QmsUiLoadError from error 79 | 80 | if widget is None: 81 | raise QmsUiLoadError 82 | 83 | self.__widget = widget 84 | self.__widget.setParent(self) 85 | 86 | layout = QVBoxLayout() 87 | layout.setContentsMargins(0, 0, 0, 0) 88 | self.setLayout(layout) 89 | layout.addWidget(self.__widget) 90 | 91 | layout = self.__widget.tab_user_groups_and_services.layout() 92 | layout.addWidget( 93 | UserGroupsBox(self.__widget.tab_user_groups_and_services) 94 | ) 95 | layout.addWidget( 96 | UserServicesBox(self.__widget.tab_user_groups_and_services) 97 | ) 98 | 99 | self.__widget.treeViewForDS.setModel(self.__ds_model) 100 | self.__widget.treeViewForDS.sortByColumn( 101 | self.__ds_model.COLUMN_GROUP_DS, Qt.SortOrder.AscendingOrder 102 | ) 103 | 104 | self.__widget.treeViewForDS.header().setSectionResizeMode( 105 | self.__ds_model.COLUMN_GROUP_DS, QHeaderView.ResizeMode.Stretch 106 | ) 107 | 108 | check_all_action = self.__widget.toolBarForDSTreeView.addAction( 109 | QIcon(":/images/themes/default/mActionShowAllLayers.svg"), 110 | self.tr("Show all"), 111 | ) 112 | check_all_action.triggered.connect(self.__ds_model.checkAll) 113 | 114 | uncheck_all_action = self.__widget.toolBarForDSTreeView.addAction( 115 | QIcon(":/images/themes/default/mActionHideAllLayers.svg"), 116 | self.tr("Hide all"), 117 | ) 118 | uncheck_all_action.triggered.connect(self.__ds_model.uncheckAll) 119 | 120 | self.__widget.btnGetContribPack.clicked.connect( 121 | self._on_get_contrib_pack 122 | ) 123 | 124 | def __load_settings(self) -> None: 125 | """Initialize widget state and signal connections.""" 126 | settings = QmsSettings() 127 | 128 | self.__widget.chkEnableOTF3857.setChecked(settings.enable_otf_3857) 129 | self.__widget.debug_logs_checkbox.setChecked( 130 | settings.is_debug_logs_enabled 131 | ) 132 | 133 | def __save_other(self, settings: QmsSettings) -> None: 134 | old_debug_enabled = settings.is_debug_logs_enabled 135 | new_debug_enabled = self.__widget.debug_logs_checkbox.isChecked() 136 | settings.is_debug_logs_enabled = new_debug_enabled 137 | if old_debug_enabled != new_debug_enabled: 138 | debug_state = "enabled" if new_debug_enabled else "disabled" 139 | update_logging_level() 140 | logger.warning(f"Debug messages were {debug_state}") 141 | 142 | @pyqtSlot() 143 | def _on_get_contrib_pack(self) -> None: 144 | """Get contributed pack.""" 145 | QgsApplication.setOverrideCursor(Qt.CursorShape.WaitCursor) 146 | try: 147 | ExtraSources().load_contrib_pack() 148 | QgsApplication.restoreOverrideCursor() 149 | 150 | info_message = self.tr( 151 | "The latest version of contributed pack was successfully downloaded!" 152 | ) 153 | QMessageBox.information(self, PLUGIN_NAME, info_message) 154 | 155 | self.__ds_model.resetModel() 156 | 157 | except Exception as error: 158 | QgsApplication.restoreOverrideCursor() 159 | 160 | error_message = self.tr( 161 | "Failed to load contributed pack:\n{}" 162 | ).format(str(error)) 163 | QMessageBox.critical(self, PLUGIN_NAME, error_message) 164 | 165 | finally: 166 | QgsApplication.restoreOverrideCursor() 167 | 168 | 169 | class QmsSettingsErrorPage(QgsOptionsPageWidget): 170 | """Error page shown if settings page fails to load. 171 | 172 | Displays an error message in the options dialog. 173 | """ 174 | 175 | def __init__(self, parent: Optional[QWidget] = None) -> None: 176 | """Initialize the error page widget. 177 | 178 | :param parent: Optional parent widget. 179 | :type parent: Optional[QWidget] 180 | """ 181 | super().__init__(parent) 182 | 183 | self.widget = QLabel( 184 | self.tr("An error occurred while loading settings page"), self 185 | ) 186 | self.widget.setAlignment(Qt.AlignmentFlag.AlignCenter) 187 | 188 | layout = QVBoxLayout() 189 | self.setLayout(layout) 190 | layout.addWidget(self.widget) 191 | 192 | def apply(self) -> None: 193 | """Apply changes (no-op for error page).""" 194 | 195 | def cancel(self) -> None: 196 | """Cancel changes (no-op for error page).""" 197 | 198 | 199 | class QmsSettingsPageFactory(QgsOptionsWidgetFactory): 200 | """ 201 | Factory registering QMS options page under QGIS Options dialog. 202 | """ 203 | 204 | def __init__(self) -> None: 205 | """Initialize the settings page factory.""" 206 | super().__init__() 207 | self.setTitle(PLUGIN_NAME) 208 | 209 | icon_path = str(Path(__file__).parents[1] / "icons" / "qms_logo.svg") 210 | self.setIcon(QIcon(icon_path)) 211 | 212 | def path(self) -> List[str]: 213 | """Return the settings page path in the options dialog. 214 | 215 | :returns: List of path elements. 216 | :rtype: List[str] 217 | """ 218 | return [COMPANY_NAME] 219 | 220 | def createWidget( 221 | self, parent: Optional[QWidget] = None 222 | ) -> Optional[QgsOptionsPageWidget]: 223 | """ 224 | Create and return the QMS options widget or error page. 225 | 226 | :param parent: Parent widget 227 | :type parent: Optional[QWidget] 228 | 229 | :return: Initialized QMS options or error page 230 | :rtype: QgsOptionsPageWidget 231 | """ 232 | try: 233 | return QmsSettingsPage(parent) 234 | except Exception: 235 | logger.exception("An error occurred while loading settings page") 236 | return QmsSettingsErrorPage(parent) 237 | -------------------------------------------------------------------------------- /src/quick_map_services/about_dialog_base.ui: -------------------------------------------------------------------------------- 1 | 2 | 3 | AboutDialogBase 4 | 5 | 6 | 7 | 0 8 | 0 9 | 652 10 | 512 11 | 12 | 13 | 14 | About {plugin_name} 15 | 16 | 17 | 18 | 12 19 | 20 | 21 | 22 | 23 | QTabWidget::North 24 | 25 | 26 | 0 27 | 28 | 29 | false 30 | 31 | 32 | 33 | Information 34 | 35 | 36 | 37 | 0 38 | 39 | 40 | 0 41 | 42 | 43 | 0 44 | 45 | 46 | 0 47 | 48 | 49 | 50 | 51 | true 52 | 53 | 54 | <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.0//EN" "http://www.w3.org/TR/REC-html40/strict.dtd"> 55 | <html><head><meta name="qrichtext" content="1" /><style type="text/css"> 56 | p, li { white-space: pre-wrap; } 57 | </style></head><body style=" font-family:'Sans Serif'; font-size:9pt; font-weight:400; font-style:normal;"> 58 | <p style="-qt-paragraph-type:empty; margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><br /></p></body></html> 59 | 60 | 61 | Qt::LinksAccessibleByMouse|Qt::TextSelectableByMouse 62 | 63 | 64 | true 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | License 73 | 74 | 75 | 76 | 0 77 | 78 | 79 | 0 80 | 81 | 82 | 0 83 | 84 | 85 | 0 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | Components 95 | 96 | 97 | 98 | 0 99 | 100 | 101 | 0 102 | 103 | 104 | 0 105 | 106 | 107 | 0 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | Contributors 117 | 118 | 119 | 120 | 0 121 | 122 | 123 | 0 124 | 125 | 126 | 0 127 | 128 | 129 | 0 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 9 142 | 143 | 144 | 145 | 146 | 3 147 | 148 | 149 | 150 | 151 | 152 | 16 153 | 75 154 | true 155 | 156 | 157 | 158 | {plugin_name} 159 | 160 | 161 | Qt::AlignCenter 162 | 163 | 164 | 165 | 166 | 167 | 168 | Version {version} 169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | 177 | Qt::Horizontal 178 | 179 | 180 | 181 | 40 182 | 20 183 | 184 | 185 | 186 | 187 | 188 | 189 | 190 | 191 | 192 | 0 193 | 194 | 195 | 196 | 197 | 198 | 0 199 | 0 200 | 201 | 202 | 203 | Get involved 204 | 205 | 206 | 207 | 208 | 209 | 210 | Qt::Horizontal 211 | 212 | 213 | QDialogButtonBox::Close 214 | 215 | 216 | 217 | 218 | 219 | 220 | 221 | 222 | 223 | 224 | button_box 225 | rejected() 226 | AboutDialogBase 227 | reject() 228 | 229 | 230 | 316 231 | 260 232 | 233 | 234 | 286 235 | 274 236 | 237 | 238 | 239 | 240 | button_box 241 | accepted() 242 | AboutDialogBase 243 | accept() 244 | 245 | 246 | 248 247 | 254 248 | 249 | 250 | 157 251 | 274 252 | 253 | 254 | 255 | 256 | 257 | -------------------------------------------------------------------------------- /src/quick_map_services/group_edit_dialog.py: -------------------------------------------------------------------------------- 1 | import codecs 2 | import os 3 | import shutil 4 | from os import path 5 | 6 | from qgis.PyQt import uic 7 | from qgis.PyQt.QtCore import pyqtSlot 8 | from qgis.PyQt.QtGui import QPixmap 9 | from qgis.PyQt.QtWidgets import QDialog, QFileDialog, QMessageBox 10 | 11 | from quick_map_services.core.settings import QmsSettings 12 | 13 | from . import extra_sources 14 | from .fixed_config_parser import FixedConfigParser 15 | from .groups_list import GroupsList 16 | from .gui.line_edit_color_validator import LineEditColorValidator 17 | 18 | FORM_CLASS, _ = uic.loadUiType( 19 | os.path.join(os.path.dirname(__file__), "group_edit_dialog.ui") 20 | ) 21 | 22 | 23 | def is_same(file1, file2): 24 | return os.path.normcase(os.path.normpath(file1)) == os.path.normcase( 25 | os.path.normpath(file2) 26 | ) 27 | 28 | 29 | class GroupEditDialog(QDialog, FORM_CLASS): 30 | def __init__(self, parent=None): 31 | """Constructor.""" 32 | super(GroupEditDialog, self).__init__(parent) 33 | self.setupUi(self) 34 | 35 | # init icon selector 36 | # self.txtIcon.set_dialog_ext(self.tr('All icon files (*.ico *.jpg *.jpeg *.png *.svg);;All files (*.*)')) 37 | # self.txtIcon.set_dialog_title(self.tr('Select icon for group')) 38 | self.iconChooseButton.clicked.connect(self.choose_icon) 39 | 40 | # validators 41 | self.id_validator = LineEditColorValidator( 42 | self.txtId, "^[A-Za-z0-9_]+$", error_tooltip=self.tr("Any text") 43 | ) 44 | self.alias_validator = LineEditColorValidator( 45 | self.txtAlias, 46 | "^[A-Za-z0-9_ ]+$", 47 | error_tooltip=self.tr("Any text"), 48 | ) 49 | 50 | # vars 51 | self.group_info = None 52 | self.init_with_existing = False 53 | 54 | self.set_icon( 55 | os.path.join(os.path.dirname(__file__), "icons", "mapservices.png") 56 | ) 57 | 58 | def set_group_info(self, group_info): 59 | self.group_info = group_info 60 | self.init_with_existing = True 61 | # feel fields 62 | self.txtId.setText(self.group_info.id) 63 | self.txtAlias.setText(self.group_info.alias) 64 | # self.txtIcon.set_path(self.group_info.icon) 65 | self.set_icon(self.group_info.icon) 66 | 67 | def fill_group_info(self, group_info): 68 | self.group_info = group_info 69 | self.init_with_existing = False 70 | # feel fields 71 | self.txtId.setText(self.group_info.id) 72 | self.txtAlias.setText(self.group_info.alias) 73 | # self.txtIcon.set_path(self.group_info.icon) 74 | self.set_icon(self.group_info.icon) 75 | 76 | @pyqtSlot() 77 | def choose_icon(self) -> None: 78 | """ 79 | Opens a file dialog to select an icon for the group and sets it. 80 | """ 81 | settings = QmsSettings() 82 | 83 | icon_path, _ = QFileDialog.getOpenFileName( 84 | self, 85 | self.tr("Select icon for group"), 86 | settings.default_user_icon_path, 87 | self.tr( 88 | "All icon files (*.ico *.jpg *.jpeg *.png *.svg);;All files (*.*)" 89 | ), 90 | ) 91 | 92 | if icon_path: 93 | settings.default_user_icon_path = icon_path 94 | self.set_icon(icon_path) 95 | 96 | def set_icon(self, icon_path): 97 | self.__group_icon = icon_path 98 | self.iconPreview.setPixmap(QPixmap(self.__group_icon)) 99 | 100 | def accept(self): 101 | if self.init_with_existing: 102 | res = self.save_existing() 103 | else: 104 | res = self.create_new() 105 | if res: 106 | super(GroupEditDialog, self).accept() 107 | 108 | def validate(self, group_id, group_alias, group_icon): 109 | checks = [ 110 | (group_id, self.tr("Please, enter group id")), 111 | (group_alias, self.tr("Please, enter group alias")), 112 | (group_icon, self.tr("Please, select icon for group")), 113 | ] 114 | 115 | for val, comment in checks: 116 | if not val: 117 | QMessageBox.critical( 118 | self, self.tr("Error on save group"), comment 119 | ) 120 | return False 121 | 122 | checks_correct = [ 123 | (self.id_validator, "Please, enter correct value for group id"), 124 | ( 125 | self.alias_validator, 126 | "Please, enter correct value for group alias", 127 | ), 128 | ] 129 | 130 | for val, comment in checks_correct: 131 | if not val.is_valid(): 132 | QMessageBox.critical( 133 | self, self.tr("Error on save group"), self.tr(comment) 134 | ) 135 | return False 136 | 137 | return True 138 | 139 | def check_existing_id(self, group_id): 140 | gl = GroupsList() 141 | if group_id in gl.groups.keys(): 142 | QMessageBox.critical( 143 | self, 144 | self.tr("Error on save group"), 145 | self.tr( 146 | "Group with such id already exists! Select new id for group!" 147 | ), 148 | ) 149 | return False 150 | return True 151 | 152 | def save_existing(self): 153 | group_id = self.txtId.text() 154 | group_alias = self.txtAlias.text() 155 | # group_icon = self.txtIcon.get_path() 156 | group_icon = self.__group_icon 157 | 158 | if not self.validate(group_id, group_alias, group_icon): 159 | return False 160 | 161 | if group_id != self.group_info.id and not self.check_existing_id( 162 | group_id 163 | ): 164 | return False 165 | 166 | if ( 167 | group_id == self.group_info.id 168 | and group_alias == self.group_info.alias 169 | and is_same(group_icon, self.group_info.icon) 170 | ): 171 | return True 172 | 173 | # replace icon if need 174 | if not is_same(group_icon, self.group_info.icon): 175 | os.remove(self.group_info.icon) 176 | 177 | dir_path = os.path.dirname(self.group_info.file_path) 178 | 179 | ico_file_name = path.basename(group_icon) 180 | ico_path = path.join(dir_path, ico_file_name) 181 | 182 | shutil.copy(group_icon, ico_path) 183 | 184 | # write config 185 | config = FixedConfigParser() 186 | 187 | config.add_section("general") 188 | config.add_section("ui") 189 | config.set("general", "id", group_id) 190 | config.set("ui", "alias", group_alias) 191 | config.set("ui", "icon", path.basename(group_icon)) 192 | 193 | with codecs.open( 194 | self.group_info.file_path, "w", "utf-8" 195 | ) as configfile: 196 | config.write(configfile) 197 | 198 | return True 199 | 200 | def create_new(self): 201 | group_id = self.txtId.text() 202 | group_alias = self.txtAlias.text() 203 | # group_icon = self.txtIcon.get_path() 204 | group_icon = self.__group_icon 205 | 206 | if not self.validate(group_id, group_alias, group_icon): 207 | return False 208 | 209 | if not self.check_existing_id(group_id): 210 | return False 211 | 212 | # set paths 213 | dir_path = path.join( 214 | extra_sources.USER_DIR_PATH, 215 | extra_sources.GROUPS_DIR_NAME, 216 | group_id, 217 | ) 218 | 219 | if path.exists(dir_path): 220 | salt = 0 221 | while path.exists(dir_path + str(salt)): 222 | salt += 1 223 | dir_path += str(salt) 224 | 225 | ini_path = path.join(dir_path, "metadata.ini") 226 | 227 | ico_file_name = path.basename(group_icon) 228 | ico_path = path.join(dir_path, ico_file_name) 229 | 230 | # create dir 231 | os.mkdir(dir_path) 232 | 233 | # copy icon 234 | shutil.copy(group_icon, ico_path) 235 | 236 | # write config 237 | config = FixedConfigParser() 238 | 239 | config.add_section("general") 240 | config.add_section("ui") 241 | config.set("general", "id", group_id) 242 | config.set("ui", "alias", group_alias) 243 | config.set("ui", "icon", ico_file_name) 244 | 245 | with codecs.open(ini_path, "w", "utf-8") as configfile: 246 | config.write(configfile) 247 | 248 | return True 249 | -------------------------------------------------------------------------------- /src/quick_map_services/core/exceptions.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import uuid 3 | from typing import Any, Callable, List, Optional, Tuple 4 | 5 | from qgis.core import QgsApplication 6 | 7 | 8 | class QmsExceptionInfoMixin: 9 | """Mixin providing common fields and logic for QuickMapServices errors and warnings.""" 10 | 11 | _error_id: str 12 | _log_message: str 13 | _user_message: str 14 | _detail: Optional[str] 15 | _try_again: Optional[Callable[[], Any]] 16 | _actions: List[Tuple[str, Callable[[], Any]]] 17 | 18 | def __init__( 19 | self, 20 | log_message: Optional[str] = None, 21 | *, 22 | user_message: Optional[str] = None, 23 | detail: Optional[str] = None, 24 | ) -> None: 25 | """Initialize the exception info mixin. 26 | 27 | :param log_message: Log message for debugging. 28 | :type log_message: Optional[str] 29 | :param user_message: Message to display to the user. 30 | :type user_message: Optional[str] 31 | :param detail: Additional details about the error. 32 | :type detail: Optional[str] 33 | """ 34 | self._error_id = str(uuid.uuid4()) 35 | 36 | default_message = QgsApplication.translate( 37 | "Exceptions", "An error occurred while running the plugin" 38 | ) 39 | 40 | self._log_message = ( 41 | log_message if log_message else default_message 42 | ).strip() 43 | 44 | self._user_message = ( 45 | user_message if user_message else default_message 46 | ).strip() 47 | 48 | Exception.__init__(self, self._log_message) # type: ignore reportArgumentType 49 | 50 | self.add_note("Message: " + self._user_message) 51 | 52 | self._detail = detail 53 | if self._detail is not None: 54 | self._detail = self._detail.strip() 55 | self.add_note("Details: " + self._detail) 56 | 57 | self._try_again = None 58 | 59 | self._actions = [] 60 | 61 | @property 62 | def error_id(self) -> str: 63 | """Get the unique error identifier. 64 | 65 | :returns: Unique error ID as a string. 66 | :rtype: str 67 | """ 68 | return self._error_id 69 | 70 | @property 71 | def log_message(self) -> str: 72 | """Get the log message for debugging. 73 | 74 | :returns: Log message. 75 | :rtype: str 76 | """ 77 | return self._log_message 78 | 79 | @property 80 | def user_message(self) -> str: 81 | """Get the message intended for the user. 82 | 83 | :returns: User message. 84 | :rtype: str 85 | """ 86 | return self._user_message 87 | 88 | @property 89 | def detail(self) -> Optional[str]: 90 | """Get additional details about the error. 91 | 92 | :returns: Error details or None. 93 | :rtype: Optional[str] 94 | """ 95 | return self._detail 96 | 97 | @property 98 | def try_again(self) -> Optional[Callable[[], Any]]: 99 | """Get the callable to retry the failed operation. 100 | 101 | :returns: Callable or None. 102 | :rtype: Optional[Callable[[], Any]] 103 | """ 104 | return self._try_again 105 | 106 | @try_again.setter 107 | def try_again(self, try_again: Optional[Callable[[], Any]]) -> None: 108 | """Set the callable to retry the failed operation. 109 | 110 | :param try_again: Callable to retry or None. 111 | :type try_again: Optional[Callable[[], Any]] 112 | """ 113 | self._try_again = try_again 114 | 115 | @property 116 | def actions(self) -> List[Tuple[str, Callable[[], Any]]]: 117 | """Get the list of available actions for this exception. 118 | 119 | :returns: List of (action_name, action_callable) tuples. 120 | :rtype: List[Tuple[str, Callable[[], Any]]] 121 | """ 122 | return self._actions 123 | 124 | def add_action(self, name: str, callback: Callable[[], Any]) -> None: 125 | """Add an action to the exception. 126 | 127 | :param name: Name of the action. 128 | :type name: str 129 | :param callback: Callable to execute for the action. 130 | :type callback: Callable[[], Any] 131 | """ 132 | self._actions.append((name, callback)) 133 | 134 | if sys.version_info < (3, 11): 135 | 136 | def add_note(self, note: str) -> None: 137 | """Add a note to the exception message (for Python < 3.11). 138 | 139 | :param note: Note string to add. 140 | :type note: str 141 | :raises TypeError: If note is not a string. 142 | """ 143 | if not isinstance(note, str): 144 | message = "Note must be a string" 145 | raise TypeError(message) 146 | message: str = self.args[0] 147 | self.args = (f"{message}\n{note}",) 148 | 149 | 150 | class QmsError(QmsExceptionInfoMixin, Exception): 151 | """Base exception for errors in the QuickMapServices plugin. 152 | 153 | Inherit from this class to define custom error types for the plugin. 154 | """ 155 | 156 | def __init__( 157 | self, 158 | log_message: Optional[str] = None, 159 | *, 160 | user_message: Optional[str] = None, 161 | detail: Optional[str] = None, 162 | ) -> None: 163 | """Initialize the error. 164 | 165 | :param log_message: Log message for debugging. 166 | :type log_message: Optional[str] 167 | :param user_message: Message to display to the user. 168 | :type user_message: Optional[str] 169 | :param detail: Additional details about the error. 170 | :type detail: Optional[str] 171 | """ 172 | QmsExceptionInfoMixin.__init__( 173 | self, 174 | log_message, 175 | user_message=user_message, 176 | detail=detail, 177 | ) 178 | Exception.__init__(self, self._log_message) 179 | 180 | 181 | class QmsWarning(QmsExceptionInfoMixin, UserWarning): 182 | """Base warning for non-critical issues in the QuickMapServices plugin. 183 | 184 | Inherit from this class to define custom warning types for the plugin. 185 | """ 186 | 187 | def __init__( 188 | self, 189 | log_message: Optional[str] = None, 190 | *, 191 | user_message: Optional[str] = None, 192 | detail: Optional[str] = None, 193 | ) -> None: 194 | """Initialize the warning. 195 | 196 | :param log_message: Log message for debugging. 197 | :type log_message: Optional[str] 198 | :param user_message: Message to display to the user. 199 | :type user_message: Optional[str] 200 | :param detail: Additional details about the error. 201 | :type detail: Optional[str] 202 | """ 203 | QmsExceptionInfoMixin.__init__( 204 | self, 205 | log_message, 206 | user_message=user_message, 207 | detail=detail, 208 | ) 209 | Exception.__init__(self, self._log_message) 210 | 211 | 212 | class QmsReloadAfterUpdateWarning(QmsWarning): 213 | """Warning raised when the plugin structure has changed after an update. 214 | 215 | This warning indicates that the plugin was successfully updated, but due to changes 216 | in its structure, it may fail to load properly until QGIS is restarted. 217 | """ 218 | 219 | def __init__(self) -> None: 220 | """Initialize the warning.""" 221 | # fmt: off 222 | super().__init__( 223 | log_message="Plugin structure changed", 224 | user_message=QgsApplication.translate( 225 | "Exceptions", 226 | "The plugin has been successfully updated. " 227 | "To continue working, please restart QGIS." 228 | ), 229 | ) 230 | # fmt: on 231 | 232 | 233 | class QmsUiLoadError(QmsError): 234 | """Exception raised when loading a UI file fails. 235 | 236 | :param log_message: Log message for debugging. 237 | :type log_message: Optional[str] 238 | :param user_message: Message to display to the user. 239 | :type user_message: Optional[str] 240 | :param detail: Additional details about the error. 241 | :type detail: Optional[str] 242 | """ 243 | 244 | def __init__( 245 | self, 246 | log_message: Optional[str] = None, 247 | *, 248 | user_message: Optional[str] = None, 249 | detail: Optional[str] = None, 250 | ) -> None: 251 | """Initialize QmsUiLoadError. 252 | 253 | :param log_message: Log message for debugging. 254 | :type log_message: Optional[str] 255 | :param user_message: Message to display to the user. 256 | :type user_message: Optional[str] 257 | :param detail: Additional details about the error. 258 | :type detail: Optional[str] 259 | """ 260 | default_message = QgsApplication.translate( 261 | "Exceptions", "Failed to load the user interface." 262 | ) 263 | log_message = log_message if log_message else default_message 264 | user_message = user_message if user_message else default_message 265 | super().__init__( 266 | log_message=log_message, 267 | user_message=user_message, 268 | detail=detail, 269 | ) 270 | --------------------------------------------------------------------------------