├── 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 | 
14 |
15 | ## YouTube
16 |
17 | [](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?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 |
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 |
179 |
180 |
181 |
182 |
183 |
184 |
--------------------------------------------------------------------------------
/src/quick_map_services/icons/qms_logo.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
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"{tag}>", "", 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"?(i|b)\b[^>]*?>", "", 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 |
--------------------------------------------------------------------------------