diff --git a/controller.py b/controller.py index de157c1..d4c289f 100644 --- a/controller.py +++ b/controller.py @@ -7,7 +7,7 @@ from .maptool import PolygonTool from .filters import FilterDefinition, Predicate -from .helpers import getPostgisLayers, removeFilterFromLayer, addFilterToLayer, refreshLayerTree, hasLayerException +from .helpers import getSupportedLayers, removeFilterFromLayer, addFilterToLayer, refreshLayerTree, hasLayerException from .settings import FILTER_COMMENT_START, FILTER_COMMENT_STOP @@ -21,32 +21,39 @@ def __init__(self, parent: Optional[QObject] = None) -> None: super().__init__(parent=parent) self.currentFilter = None self.rubberBands = [] + self.connectSignals() - def removeFilter(self) -> None: - self.currentFilter = None - self.refreshFilter() + def connectSignals(self): + QgsProject.instance().layersAdded.connect(self.onLayersAdded) - def updateConnectionProjectLayersAdded(self): - self.disconnectProjectLayersAdded() - if self.hasValidFilter(): - QgsProject.instance().layersAdded.connect(self.onLayersAdded) + def disconnectSignals(self): + QgsProject.instance().layersAdded.disconnect(self.onLayersAdded) - def disconnectProjectLayersAdded(self): - try: - QgsProject.instance().layersAdded.disconnect(self.onLayersAdded) - except TypeError: - pass + def removeFilter(self): + self.currentFilter = None + self.refreshFilter() def onLayersAdded(self, layers: Iterable[QgsMapLayer]): - if not self.currentFilter.isValid: - return - for layer in getPostgisLayers(layers): - filterCondition = self.currentFilter.filterString(layer) - filterString = f'{FILTER_COMMENT_START}{filterCondition}{FILTER_COMMENT_STOP}' - layer.setSubsetString(filterString) + if self.hasValidFilter(): + # Apply the filter to added layers or loaded project + for layer in getSupportedLayers(layers): + filterCondition = self.currentFilter.filterString(layer) + filterString = f'{FILTER_COMMENT_START}{filterCondition}{FILTER_COMMENT_STOP}' + layer.setSubsetString(filterString) + else: + # Look for saved filters to use with the plugin (possible when project was loaded) + for layer in getSupportedLayers(layers): + if FILTER_COMMENT_START in layer.subsetString(): + self.setFilterFromLayer(layer) + return + + def setFilterFromLayer(self, layer): + filterDefinition = FilterDefinition.fromFilterString(layer.subsetString()) + self.currentFilter = filterDefinition + self.refreshFilter() def updateLayerFilters(self): - for layer in getPostgisLayers(QgsProject.instance().mapLayers().values()): + for layer in getSupportedLayers(QgsProject.instance().mapLayers().values()): if self.hasValidFilter() and not hasLayerException(layer): addFilterToLayer(layer, self.currentFilter) else: @@ -54,7 +61,6 @@ def updateLayerFilters(self): refreshLayerTree() def updateProjectLayers(self): - self.updateConnectionProjectLayersAdded() self.updateLayerFilters() def refreshFilter(self): diff --git a/filters.py b/filters.py index 8295753..abde93e 100644 --- a/filters.py +++ b/filters.py @@ -6,7 +6,11 @@ from qgis.core import QgsVectorLayer, QgsGeometry, QgsCoordinateReferenceSystem from qgis.utils import iface -from .helpers import tr, saveSettingsValue, readSettingsValue, allSettingsValues, removeSettingsValue, getLayerGeomName +from .settings import FILTER_COMMENT_START, FILTER_COMMENT_STOP +from .helpers import tr, saveSettingsValue, readSettingsValue, allSettingsValues, removeSettingsValue, getLayerGeomName, matchFormatString + + +FILTERSTRING_TEMPLATE = "{spatial_predicate}({geom_name}, ST_TRANSFORM(ST_GeomFromText('{wkt}', {srid}), {layer_srid}))" class Predicate(IntEnum): @@ -33,6 +37,10 @@ def __lt__(self, other): def geometry(self) -> QgsGeometry: return QgsGeometry.fromWkt(self.wkt) + @property + def boxGeometry(self) -> QgsGeometry: + return QgsGeometry.fromRect(self.geometry.boundingBox()) + def filterString(self, layer: QgsVectorLayer) -> str: """Returns a layer filter string corresponding to the filter definition. @@ -42,8 +50,6 @@ def filterString(self, layer: QgsVectorLayer) -> str: Returns: str: A layer filter string """ - template = "{spatial_predicate}({geom_name}, ST_TRANSFORM(ST_GeomFromText('{wkt}', {srid}), {layer_srid}))" - # ST_DISJOINT does not use spatial indexes, but we can use its opposite "NOT ST_INTERSECTS" which does spatial_predicate = f"ST_{Predicate(self.predicate).name}" if self.predicate == Predicate.DISJOINT: @@ -51,11 +57,11 @@ def filterString(self, layer: QgsVectorLayer) -> str: wkt = self.wkt if self.bbox: - rect = QgsGeometry.fromWkt(self.wkt).boundingBox() - wkt = QgsGeometry.fromRect(rect).asWkt() + wkt = self.boxGeometry.asWkt() geom_name = getLayerGeomName(layer) - return template.format( + + return FILTERSTRING_TEMPLATE.format( spatial_predicate=spatial_predicate, geom_name=geom_name, wkt=wkt, @@ -63,6 +69,26 @@ def filterString(self, layer: QgsVectorLayer) -> str: layer_srid=layer.crs().postgisSrid() ) + @staticmethod + def fromFilterString(subsetString: str) -> 'FilterDefinition': + start_index = subsetString.find(FILTER_COMMENT_START) + len(FILTER_COMMENT_START) + stop_index = subsetString.find(FILTER_COMMENT_STOP) + filterString = subsetString[start_index: stop_index] + filterString = filterString.replace(' AND ', '') + params = matchFormatString(FILTERSTRING_TEMPLATE, filterString) + predicateName = params['spatial_predicate'][len('ST_'):] + if filterString.startswith('NOT ST_INTERSECTS'): + predicateName = 'DISJOINT' + predicate = Predicate[predicateName] + filterDefinition = FilterDefinition( + name=tr('Unknown filter'), + wkt=params['wkt'], + crs=QgsCoordinateReferenceSystem(int(params['srid'])), + predicate=predicate.value, + bbox=False + ) + return updateFilterNameFromStorage(filterDefinition) + @property def storageDict(self) -> dict: """Returns a text serialisation of the FilterDefinition. @@ -134,6 +160,18 @@ def deleteFilterDefinition(filterDef: FilterDefinition) -> None: removeSettingsValue(filterDef.name) +def updateFilterNameFromStorage(filterDef: FilterDefinition) -> FilterDefinition: + for storageFilter in loadAllFilterDefinitions(): + if filterDef.crs == storageFilter.crs and filterDef.wkt == storageFilter.wkt: + storageFilter.predicate = filterDef.predicate + return storageFilter + if filterDef.crs == storageFilter.crs and filterDef.wkt == storageFilter.boxGeometry.asWkt(): + storageFilter.predicate = filterDef.predicate + storageFilter.bbox = True + return storageFilter + return filterDef + + def askApply() -> bool: txt = tr('Current settings will be lost. Apply anyway?') return QMessageBox.question(iface.mainWindow(), tr('Continue?'), txt, diff --git a/helpers.py b/helpers.py index 49cbd98..5515ede 100644 --- a/helpers.py +++ b/helpers.py @@ -1,9 +1,10 @@ +import re from typing import Any, List, Iterable from PyQt5.QtCore import QCoreApplication from qgis.core import QgsExpressionContextUtils, QgsSettings, QgsMapLayer, QgsMapLayerType, QgsVectorLayer -from .settings import GROUP, FILTER_COMMENT_START, FILTER_COMMENT_STOP, LAYER_EXCEPTION_VARIABLE +from .settings import SUPPORTED_PROVIDERS, GROUP, FILTER_COMMENT_START, FILTER_COMMENT_STOP, LAYER_EXCEPTION_VARIABLE def tr(message): @@ -44,11 +45,11 @@ def refreshLayerTree() -> None: pass -def getPostgisLayers(layers: Iterable[QgsMapLayer]): +def getSupportedLayers(layers: Iterable[QgsMapLayer]): for layer in layers: if layer.type() != QgsMapLayerType.VectorLayer: continue - if layer.providerType() != 'postgres': + if layer.providerType() not in SUPPORTED_PROVIDERS: continue yield layer @@ -85,12 +86,39 @@ def setLayerException(layer: QgsVectorLayer, exception: bool) -> None: QgsExpressionContextUtils.setLayerVariable(layer, LAYER_EXCEPTION_VARIABLE, exception) -def getTestFilterDefinition(): - from .filters import Predicate, FilterDefinition - name = 'museumsinsel' - srsid = 3452 # 4326 - predicate = Predicate.INTERSECTS.value - wkt = 'Polygon ((13.38780495720708963 52.50770539474106613, 13.41583642354597039 52.50770539474106613, ' \ - '13.41583642354597039 52.52548910505585411, 13.38780495720708963 52.52548910505585411, ' \ - '13.38780495720708963 52.50770539474106613))' - return FilterDefinition(name, wkt, srsid, predicate) +def matchFormatString(format_str: str, s: str) -> dict: + """Match s against the given format string, return dict of matches. + + We assume all of the arguments in format string are named keyword arguments (i.e. no {} or + {:0.2f}). We also assume that all chars are allowed in each keyword argument, so separators + need to be present which aren't present in the keyword arguments (i.e. '{one}{two}' won't work + reliably as a format string but '{one}-{two}' will if the hyphen isn't used in {one} or {two}). + + We raise if the format string does not match s. + + Example: + fs = '{test}-{flight}-{go}' + s = fs.format('first', 'second', 'third') + match_format_string(fs, s) -> {'test': 'first', 'flight': 'second', 'go': 'third'} + + source: https://stackoverflow.com/questions/10663093/use-python-format-string-in-reverse-for-parsing + """ + + # First split on any keyword arguments, note that the names of keyword arguments will be in the + # 1st, 3rd, ... positions in this list + tokens = re.split(r'\{(.*?)\}', format_str) + keywords = tokens[1::2] + + # Now replace keyword arguments with named groups matching them. We also escape between keyword + # arguments so we support meta-characters there. Re-join tokens to form our regexp pattern + tokens[1::2] = map(u'(?P<{}>.*)'.format, keywords) + tokens[0::2] = map(re.escape, tokens[0::2]) + pattern = ''.join(tokens) + + # Use our pattern to match the given string, raise if it doesn't match + matches = re.match(pattern, s) + if not matches: + raise Exception("Format string did not match") + + # Return a dict with all of our keywords and their values + return {x: matches.group(x) for x in keywords} \ No newline at end of file diff --git a/map_filter.py b/map_filter.py index 6eb8b15..48b74d4 100644 --- a/map_filter.py +++ b/map_filter.py @@ -49,4 +49,5 @@ def initGui(self): def unload(self): self.toolbar.hideFilterGeom() self.toolbar.controller.removeFilter() + self.toolbar.controller.disconnectSignals() self.toolbar.deleteLater() diff --git a/widgets.py b/widgets.py index a48cb39..e9dcb32 100644 --- a/widgets.py +++ b/widgets.py @@ -368,15 +368,8 @@ def onFilterChanged(self, filterDef: Optional[FilterDefinition]): def changeDisplayedName(self, filterDef: FilterDefinition): if filterDef and filterDef.isValid: self.labelFilterName.setText(filterDef.name) - # self.setItalicName(not filterDef.isSaved) else: self.labelFilterName.setText(self.tr("No filter geometry set")) - # self.setItalicName(True) - - def setItalicName(self, italic: bool): - font = self.labelFilterName.font() - font.setItalic(italic) - self.labelFilterName.setFont(font) def startFilterFromExtentDialog(self): dlg = ExtentDialog(self.controller, parent=self) @@ -406,7 +399,7 @@ def showFilterGeom(self): filterRubberBand = QgsRubberBand(iface.mapCanvas(), QgsWkbTypes.PolygonGeometry) filterGeom = self.controller.currentFilter.geometry if self.controller.currentFilter.bbox: - filterGeom = QgsGeometry.fromRect(filterGeom.boundingBox()) + filterGeom = self.controller.currentFilter.boxGeometry filterCrs = self.controller.currentFilter.crs projectCrs = QgsCoordinateReferenceSystem(QgsProject.instance().crs()) filterProj = QgsCoordinateTransform(filterCrs, projectCrs, QgsProject.instance())