diff --git a/gui/icons/grass/STR3DS.png b/gui/icons/grass/STR3DS.png new file mode 100644 index 00000000000..1d5748a698a Binary files /dev/null and b/gui/icons/grass/STR3DS.png differ diff --git a/gui/icons/grass/STRDS.png b/gui/icons/grass/STRDS.png new file mode 100644 index 00000000000..03f29f66638 Binary files /dev/null and b/gui/icons/grass/STRDS.png differ diff --git a/gui/icons/grass/STVDS.png b/gui/icons/grass/STVDS.png new file mode 100644 index 00000000000..3e143f0d6b5 Binary files /dev/null and b/gui/icons/grass/STVDS.png differ diff --git a/gui/wxpython/core/treemodel.py b/gui/wxpython/core/treemodel.py index e4f80be5d31..e653ad7fcaf 100644 --- a/gui/wxpython/core/treemodel.py +++ b/gui/wxpython/core/treemodel.py @@ -148,9 +148,17 @@ def RemoveNode(self, node): del node.children[:] def SortChildren(self, node): - """Sorts children with 'natural sort' based on label.""" + """Sorts children with 'natural sort' based on label. + Pins temporal datasets to the top.""" if node.children: - naturally_sort(node.children, key=lambda node: node.label) + + def custom_sort_key(n): + # Pin STDS to the top + if n.data.get("type") in {"strds", "stvds", "str3ds"}: + return "0_" + n.label + return "1_" + n.label + + naturally_sort(node.children, key=custom_sort_key) def Filtered(self, **kwargs): """Filters model based on parameters in kwargs diff --git a/gui/wxpython/datacatalog/tree.py b/gui/wxpython/datacatalog/tree.py index dca1043ba16..0db0766c338 100644 --- a/gui/wxpython/datacatalog/tree.py +++ b/gui/wxpython/datacatalog/tree.py @@ -66,6 +66,7 @@ from grass.pydispatch.signal import Signal import grass.script as gs +from grass.tools import Tools, ToolError from grass.script import gisenv from grass.grassdb.data import map_exists from grass.grassdb.checks import ( @@ -76,9 +77,19 @@ ) from grass.exceptions import CalledModuleError +tools = Tools() + +STDS_TYPES = {"strds", "stvds", "str3ds"} +STDS_TO_MAP_TYPE = {"strds": "raster", "stvds": "vector", "str3ds": "raster_3d"} +STDS_LIST_CMD = { + "strds": "t_rast_list", + "stvds": "t_vect_list", + "str3ds": "t_rast3d_list", +} + def getLocationTree(gisdbase, location, queue, mapsets=None, lazy=False): - """Creates dictionary with mapsets, elements, layers for given location. + """Creates dictionary with mapsets, stds, elements, layers for given location. Returns tuple with the dictionary and error (or None)""" tmp_gisrc_file, env = gs.create_environment(gisdbase, location, "PERMANENT") env["GRASS_SKIP_MAPSET_OWNER_CHECK"] = "1" @@ -134,6 +145,22 @@ def getLocationTree(gisdbase, location, queue, mapsets=None, lazy=False): name, mapset = wholename.split("@", maxsplit=1) maps_dict[mapset].append({"name": name, "type": ltype}) + try: + for t_type in ["strds", "stvds", "str3ds"]: + items = tools.t_list( + type=t_type, format="json", mapset="*", quiet=True, env=env + ) + + if items: + for item in items: + m_set = item["mapset"] + d_name = item["name"] + if m_set and d_name and m_set in maps_dict: + maps_dict[m_set].append({"name": d_name, "type": t_type}) + + except ToolError as e: + Debug.msg(1, "Temporal fetch failed: {0}".format(e)) + queue.put((maps_dict, None)) gs.try_remove(tmp_gisrc_file) @@ -271,6 +298,9 @@ def __init__( "grassdb", "location", "mapset", + "strds", + "stvds", + "str3ds", "raster", "vector", "raster_3d", @@ -345,6 +375,8 @@ def _resetSelectVariables(self): """Reset variables related to item selection.""" self.selected_grassdb = [] self.selected_layer = [] + self.selected_stds = [] + self.selected_stds_map = [] self.selected_mapset = [] self.selected_location = [] self.mixed = False @@ -438,6 +470,79 @@ def _saveGrassDBs(self): UserSettings.SaveToFile(grassdbSettings) + def _reloadDatasetNode(self, dataset_node, mapset_node): + """Recursively reload the model of a specific temporal dataset node""" + if dataset_node.children: + del dataset_node.children[:] + + dataset_name = dataset_node.data["name"] + mapset_name = mapset_node.data["name"] + + # Traverse the tree via node.parent instead of using + # self.selected_location[0] or self.selected_grassdb[0]. + # In standalone mode (when a user runs `g.gui.datacatalog`), + # the selection arrays are empty and will throw an IndexError. + location_node = mapset_node.parent + grassdb_node = location_node.parent + + ds_type = dataset_node.data["type"] + + list_cmd = STDS_LIST_CMD[ds_type] + child_type = STDS_TO_MAP_TYPE[ds_type] + + gisrc, env = gs.create_environment( + grassdb_node.data["name"], + location_node.data["name"], + mapset_name, + ) + + self.busy = wx.BusyCursor() + try: + items = getattr(tools, list_cmd)( + input=f"{dataset_name}@{mapset_name}", + columns="id,name", + format="json", + quiet=True, + env=env, + ) + + if items: + for item in items["data"]: + self._model.AppendNode( + parent=dataset_node, + data={ + "type": child_type, + "name": item["name"], + "map_id": item["id"], + }, + ) + except ToolError as e: + Debug.msg(1, "Lazy load failed: {0}".format(e)) + finally: + gs.try_remove(gisrc) + del self.busy + + def _reloadMapsetNodeWithExpandedStds(self, mapset_node): + """Reload mapset node while preserving the expanded state of STDS nodes. + + Records which STDS nodes are currently expanded, reloads the entire + mapset, then re-expands those STDS nodes and reloads their children. + """ + expanded_stds = set() + for child in mapset_node.children: + if child.data.get("type") in STDS_TYPES and self.IsNodeExpanded(child): + expanded_stds.add((child.data["name"], child.data["type"])) + + self._reloadMapsetNode(mapset_node) + self.RefreshNode(mapset_node, recursive=True) + + for child in mapset_node.children: + key = (child.data["name"], child.data["type"]) + if key in expanded_stds: + self._reloadDatasetNode(child, mapset_node) + self.RefreshNode(child, recursive=True) + self.ExpandNode(child, recursive=False) + def _reloadMapsetNode(self, mapset_node): """Recursively reload the model of a specific mapset node""" if mapset_node.children: @@ -724,8 +829,10 @@ def EnableWatchingMapset(self): EVT_CURRENT_MAPSET_CHANGED, lambda evt: self._updateAfterMapsetChanged() ) - def GetDbNode(self, grassdb, location=None, mapset=None, map=None, map_type=None): - """Returns node representing db/location/mapset/map or None if not found.""" + def GetDbNode( + self, grassdb, location=None, mapset=None, element_name=None, element_type=None + ): + """Returns node representing db/location/mapset/map(or stds) or None if not found.""" grassdb_nodes = self._model.SearchNodes(name=grassdb, type="grassdb") if grassdb_nodes: if not location: @@ -740,10 +847,10 @@ def GetDbNode(self, grassdb, location=None, mapset=None, map=None, map_type=None parent=location_nodes[0], name=mapset, type="mapset" ) if mapset_nodes: - if not map: + if not element_name: return mapset_nodes[0] map_nodes = self._model.SearchNodes( - parent=mapset_nodes[0], name=map, type=map_type + parent=mapset_nodes[0], name=element_name, type=element_type ) if map_nodes: return map_nodes[0] @@ -831,8 +938,7 @@ def ReloadCurrentMapset(self): ): return - self._reloadMapsetNode(self.current_mapset_node) - self.RefreshNode(self.current_mapset_node, recursive=True) + self._reloadMapsetNodeWithExpandedStds(self.current_mapset_node) def _populateMapsetItem(self, mapset_node, data): for item in data: @@ -845,6 +951,9 @@ def _initImages(self): "grassdb": MetaIcon(img="grassdb").GetBitmap(bmpsize), "location": MetaIcon(img="location").GetBitmap(bmpsize), "mapset": MetaIcon(img="mapset").GetBitmap(bmpsize), + "strds": MetaIcon(img="STRDS").GetBitmap(bmpsize), + "stvds": MetaIcon(img="STVDS").GetBitmap(bmpsize), + "str3ds": MetaIcon(img="STR3DS").GetBitmap(bmpsize), "raster": MetaIcon(img="raster").GetBitmap(bmpsize), "vector": MetaIcon(img="vector").GetBitmap(bmpsize), "raster_3d": MetaIcon(img="raster3d").GetBitmap(bmpsize), @@ -865,25 +974,54 @@ def DefineItems(self, selected): for item in selected: type = item.data["type"] if type in {"raster", "raster_3d", "vector"}: - self.selected_layer.append(item) + if item.parent.data["type"] in STDS_TYPES: + self.selected_layer.append(item) + self.selected_stds.append(None) + self.selected_stds_map.append(item) + mixed.append("stds_map") + else: + self.selected_layer.append(item) + self.selected_stds.append(None) + self.selected_stds_map.append(None) + mixed.append("layer") + + parent_node = item.parent + while parent_node and parent_node.data["type"] != "mapset": + parent_node = parent_node.parent + + self.selected_mapset.append(parent_node) + self.selected_location.append(parent_node.parent) + self.selected_grassdb.append(parent_node.parent.parent) + + elif type in STDS_TYPES: + self.selected_layer.append(None) + self.selected_stds.append(item) + self.selected_stds_map.append(None) + self.selected_mapset.append(item.parent) self.selected_location.append(item.parent.parent) self.selected_grassdb.append(item.parent.parent.parent) - mixed.append("layer") + mixed.append("stds") elif type == "mapset": self.selected_layer.append(None) + self.selected_stds.append(None) + self.selected_stds_map.append(None) self.selected_mapset.append(item) self.selected_location.append(item.parent) self.selected_grassdb.append(item.parent.parent) mixed.append("mapset") elif type == "location": self.selected_layer.append(None) + self.selected_stds.append(None) + self.selected_stds_map.append(None) self.selected_mapset.append(None) self.selected_location.append(item) self.selected_grassdb.append(item.parent) mixed.append("location") elif type == "grassdb": self.selected_layer.append(None) + self.selected_stds.append(None) + self.selected_stds_map.append(None) self.selected_mapset.append(None) self.selected_location.append(None) self.selected_grassdb.append(item) @@ -902,10 +1040,20 @@ def OnRightClick(self, node): self._popupMenuEmpty() return - if not self.selected_layer: + if not self.selected_layer and not self.selected_stds: self._popupMenuEmpty() + elif self.selected_stds_map[0]: + self._popupMenuStdsMap() elif self.selected_layer[0]: self._popupMenuLayer() + elif self.selected_stds[0] and len(self.selected_stds) == 1: + self._popupMenuStds() + elif ( + self.selected_stds[0] + and len(self.selected_stds) > 1 + and not self.selected_layer[0] + ): + self._popupMenuMultipleStds() elif self.selected_mapset[0] and len(self.selected_mapset) == 1: self._popupMenuMapset() elif ( @@ -979,7 +1127,20 @@ def OnDoubleClick(self, node): if node.data["type"] == "mapset" and not node.children: self._reloadMapsetNode(node) self.RefreshNode(node, recursive=True) - if node.data["type"] in {"mapset", "location", "grassdb"}: + + if node.data["type"] in STDS_TYPES and not node.children: + self._reloadDatasetNode(node, node.parent) + self.RefreshNode(node, recursive=True) + + if ( + node.data["type"] + in { + "mapset", + "location", + "grassdb", + } + | STDS_TYPES + ): # expand/collapse location/mapset... if self.IsNodeExpanded(node): self.CollapseNode(node, recursive=False) @@ -1145,6 +1306,17 @@ def OnCreateMapset(self, event): """Create new mapset""" self.CreateMapset(self.selected_grassdb[0], self.selected_location[0]) + def OnCreateStds(self, event): + """Launch t.create dialog to create a new temporal dataset""" + mapset_node = self.selected_mapset[0] + + self._giface.RunCmd( + ["t.create", "--ui"], + onDone=lambda e: wx.CallAfter( + lambda: self._reloadMapsetNodeWithExpandedStds(mapset_node) + ), + ) + def CreateLocation(self, grassdb_node): """ Creates new location interactively and adds it to the tree and switch @@ -1193,6 +1365,50 @@ def OnRenameMapset(self, event): newname=newmapset, ) + def OnRenameStds(self, event): + """Rename temporal dataset natively using t.rename""" + old_name = self.selected_stds[0].data["name"] + stds_type = self.selected_stds[0].data["type"] + + gisrc, env = gs.create_environment( + self.selected_grassdb[0].data["name"], + self.selected_location[0].data["name"], + self.selected_mapset[0].data["name"], + ) + + new_name = self._getNewMapName( + _("New name"), + _("Rename dataset"), + old_name, + env=env, + mapset=self.selected_mapset[0].data["name"], + element=stds_type, + ) + + if new_name: + string = f"{old_name},{new_name}" + label = _("Renaming dataset <{name}>...").format(name=string) + self.showNotification.emit(message=label) + + renamed, cmd = self._runCommand( + "t.rename", input=old_name, output=new_name, type=stds_type, env=env + ) + + if renamed == 0: + self.showNotification.emit( + message=_("{cmd} -- completed").format(cmd=cmd) + ) + self._giface.grassdbChanged.emit( + grassdb=self.selected_grassdb[0].data["name"], + location=self.selected_location[0].data["name"], + mapset=self.selected_mapset[0].data["name"], + map=old_name, + element=stds_type, + newname=new_name, + action="rename", + ) + gs.try_remove(gisrc) + def OnRenameLocation(self, event): """ Rename selected location @@ -1611,51 +1827,185 @@ def InsertGrassDb(self, name): return grassdb_node def OnDeleteMap(self, event): - """Delete layer or mapset""" + """Delete selected layers/maps""" names = [ - self.selected_layer[i].data["name"] - + "@" - + self.selected_mapset[i].data["name"] + "{name}@{mapset}".format( + name=self.selected_layer[i].data["name"], + mapset=self.selected_mapset[i].data["name"], + ) for i in range(len(self.selected_layer)) ] - if len(names) < 10: + + # Check which maps are registered in temporal datasets + registered_maps = set() + status_tools = Tools(errors="ignore", consistent_return_value=True) + for i in range(len(self.selected_layer)): + map_name = names[i] + map_type = self.selected_layer[i].data["type"] + gisrc, env = gs.create_environment( + self.selected_grassdb[i].data["name"], + self.selected_location[i].data["name"], + self.selected_mapset[i].data["name"], + ) + try: + if ( + status_tools.t_info( + type=map_type, + input=map_name, + env=env, + quiet=True, + ).returncode + == 0 + ): + registered_maps.add(map_name) + finally: + gs.try_remove(gisrc) + + if len(names) <= 3: question = _("Do you really want to delete map(s) <{m}>?").format( m=", ".join(names) ) else: question = _("Do you really want to delete {n} maps?").format(n=len(names)) - if self._confirmDialog(question, title=_("Delete map")) == wx.ID_YES: - label = _("Deleting {name}...").format(name=names) - self.showNotification.emit(message=label) - for i in range(len(self.selected_layer)): - gisrc, env = gs.create_environment( - self.selected_grassdb[i].data["name"], - self.selected_location[i].data["name"], - self.selected_mapset[i].data["name"], - ) - removed, cmd = self._runCommand( - "g.remove", - flags="f", - type=self.selected_layer[i].data["type"], - name=self.selected_layer[i].data["name"], - env=env, - ) - gs.try_remove(gisrc) - if removed == 0: - self._giface.grassdbChanged.emit( - grassdb=self.selected_grassdb[i].data["name"], - location=self.selected_location[i].data["name"], - mapset=self.selected_mapset[i].data["name"], - element=self.selected_layer[i].data["type"], - map=self.selected_layer[i].data["name"], - action="delete", + + if registered_maps: + question += "\n\n" + _( + "Warning: {maps} {verb} registered in temporal dataset(s) " + "and will be unregistered first." + ).format( + maps=( + "<{}>".format(">, <".join(registered_maps)) + if len(registered_maps) < 6 + else _("{n} maps").format(n=len(registered_maps)) + ), + verb=_("is") if len(registered_maps) == 1 else _("are"), + ) + dlg = wx.MessageDialog(self, question, _("Delete map"), wx.YES_NO) + dlg.SetYesNoLabels(_("Unregister and delete"), _("Cancel")) + else: + dlg = wx.MessageDialog(self, question, _("Delete map"), wx.YES_NO) + + if dlg.ShowModal() != wx.ID_YES: + dlg.Destroy() + return + dlg.Destroy() + + if len(names) <= 3: + label = _("Deleting {name}...").format(name=", ".join(names)) + else: + label = _("Deleting {n} maps...").format(n=len(names)) + + self.showNotification.emit(message=label) + for i in range(len(self.selected_layer)): + gisrc, env = gs.create_environment( + self.selected_grassdb[i].data["name"], + self.selected_location[i].data["name"], + self.selected_mapset[i].data["name"], + ) + + # Unregister from temporal database before deleting + if names[i] in registered_maps: + map_type = self.selected_layer[i].data["type"] + try: + tools.t_unregister( + type=map_type, + maps=names[i], + env=env, + quiet=True, ) + except ToolError as e: Debug.msg( - 1, "LAYER " + self.selected_layer[i].data["name"] + " DELETED" + 1, + "Failed to unregister {name}: {err}".format( + name=names[i], err=e + ), ) - self.UnselectAll() - self.showNotification.emit(message=_("g.remove completed")) + removed, cmd = self._runCommand( + "g.remove", + flags="f", + type=self.selected_layer[i].data["type"], + name=self.selected_layer[i].data["name"], + env=env, + ) + gs.try_remove(gisrc) + if removed == 0: + self._giface.grassdbChanged.emit( + grassdb=self.selected_grassdb[i].data["name"], + location=self.selected_location[i].data["name"], + mapset=self.selected_mapset[i].data["name"], + element=self.selected_layer[i].data["type"], + map=self.selected_layer[i].data["name"], + action="delete", + ) + Debug.msg( + 1, "LAYER " + self.selected_layer[i].data["name"] + " DELETED" + ) + + self.UnselectAll() + self.showNotification.emit(message=_("g.remove completed")) + + def OnDeleteStds(self, event): + """Delete one or multiple temporal datasets using t.remove""" + names = [stds.data["name"] for stds in self.selected_stds] + + if len(names) <= 3: + question = _("Do you really want to delete dataset(s) <{m}>?").format( + m=", ".join(names) + ) + else: + question = _("Do you really want to delete {n} datasets?").format( + n=len(names) + ) + + dlg = wx.RichMessageDialog( + self, question, _("Delete dataset"), wx.YES_NO | wx.NO_DEFAULT + ) + dlg.ShowCheckBox(_("Also unregister maps from temporal database"), checked=True) + + if dlg.ShowModal() != wx.ID_YES: + dlg.Destroy() + return + + unregister_maps = dlg.IsCheckBoxChecked() + dlg.Destroy() + + self.showNotification.emit(message=_("Deleting datasets...")) + + for stds_node, grassdb_node, location_node, mapset_node in zip( + self.selected_stds, + self.selected_grassdb, + self.selected_location, + self.selected_mapset, + strict=True, + ): + stds_type = stds_node.data["type"] + stds_name = stds_node.data["name"] + + gisrc, env = gs.create_environment( + grassdb_node.data["name"], + location_node.data["name"], + mapset_node.data["name"], + ) + + flags = "rf" if unregister_maps else "f" + removed, cmd = self._runCommand( + "t.remove", inputs=stds_name, type=stds_type, flags=flags, env=env + ) + gs.try_remove(gisrc) + + if removed == 0: + self._giface.grassdbChanged.emit( + grassdb=grassdb_node.data["name"], + location=location_node.data["name"], + mapset=mapset_node.data["name"], + element=stds_type, + map=stds_name, + action="delete", + ) + + self.UnselectAll() + self.showNotification.emit(message=_("t.remove completed")) def OnDeleteMapset(self, event): """ @@ -1772,12 +2122,14 @@ def DisplayLayer(self): all_names = [] names = {"raster": [], "vector": [], "raster_3d": []} for i in range(len(self.selected_layer)): - name = ( - self.selected_layer[i].data["name"] - + "@" - + self.selected_mapset[i].data["name"] + if self.selected_layer[i] is None: + continue + item_data = self.selected_layer[i].data + name = item_data.get( + "map_id", f"{item_data['name']}@{self.selected_mapset[i].data['name']}" ) - names[self.selected_layer[i].data["type"]].append(name) + + names[item_data["type"]].append(name) all_names.append(name) # if self.selected_location[0].data['name'] == gisenv()['LOCATION_NAME'] and # self.selected_mapset[0]: @@ -1948,8 +2300,8 @@ def _updateAfterGrassdbChanged( grassdb=grassdb, location=location, mapset=mapset, - map=map, - map_type=element, + element_name=map, + element_type=element, ) if node: self._model.RemoveNode(node) @@ -1959,8 +2311,38 @@ def _updateAfterGrassdbChanged( grassdb=grassdb, location=location, mapset=mapset, - map=map, - map_type=element, + element_name=map, + element_type=element, + ) + if node: + self._renameNode(node, newname) + + elif element in STDS_TYPES: + if action == "new": + node = self.GetDbNode(grassdb=grassdb, location=location, mapset=mapset) + if node: + self._reloadMapsetNode(node) + self.RefreshNode(node, recursive=True) + + elif action == "delete": + node = self.GetDbNode( + grassdb=grassdb, + location=location, + mapset=mapset, + element_name=map, + element_type=element, + ) + if node: + self._model.RemoveNode(node) + self.RefreshNode(node.parent, recursive=True) + + elif action == "rename": + node = self.GetDbNode( + grassdb=grassdb, + location=location, + mapset=mapset, + element_name=map, + element_type=element, ) if node: self._renameNode(node, newname) @@ -1979,8 +2361,7 @@ def _updateAfterMapsetChanged(self): else: self.InsertMapset(genv["MAPSET"], location_node) self.UpdateCurrentDbLocationMapsetNode() - self._reloadMapsetNode(self.current_mapset_node) - self.RefreshNode(self.current_mapset_node, recursive=True) + self._reloadMapsetNodeWithExpandedStds(self.current_mapset_node) self.ExpandCurrentMapset() self.RefreshItems() self._mapset_watchdog.ScheduleWatchCurrentMapset() @@ -1999,14 +2380,13 @@ def done(event): cmd = ["v.info"] elif self.selected_layer[i].data["type"] == "raster_3d": cmd = ["r3.info"] - cmd.append( - "map=%s@%s" - % ( - self.selected_layer[i].data["name"], - self.selected_mapset[i].data["name"], - ) + map_name = self.selected_layer[i].data.get( + "map_id", + f"{self.selected_layer[i].data['name']}@{self.selected_mapset[i].data['name']}", ) + cmd.append(f"map={map_name}") + gisrc, env = gs.create_environment( self.selected_grassdb[i].data["name"], self.selected_location[i].data["name"], @@ -2016,6 +2396,24 @@ def done(event): # temp gisrc file must be deleted onDone self._giface.RunCmd(cmd, env=env, onDone=done, userData=gisrc) + def OnMetadataStds(self, event): + """Show metadata natively using t.info""" + + def done(event): + gs.try_remove(event.userData) + + stds_type = self.selected_stds[0].data["type"] + stds_name = self.selected_stds[0].data["name"] + mapset_name = self.selected_mapset[0].data["name"] + + cmd = ["t.info", f"type={stds_type}", f"input={stds_name}@{mapset_name}"] + gisrc, env = gs.create_environment( + self.selected_grassdb[0].data["name"], + self.selected_location[0].data["name"], + mapset_name, + ) + self._giface.RunCmd(cmd, env=env, onDone=done, userData=gisrc) + def OnShowProjection(self, event): """Show projection info of selected project (location)""" @@ -2050,13 +2448,11 @@ def OnCopyName(self, event): do = wx.TextDataObject() text = [] for i in range(len(self.selected_layer)): - text.append( - "%s@%s" - % ( - self.selected_layer[i].data["name"], - self.selected_mapset[i].data["name"], - ) + map_name = self.selected_layer[i].data.get( + "map_id", + f"{self.selected_layer[i].data['name']}@{self.selected_mapset[i].data['name']}", ) + text.append(map_name) do.SetText(",".join(text)) wx.TheClipboard.SetData(do) wx.TheClipboard.Close() @@ -2089,8 +2485,7 @@ def Filter(self, text, element=None): def OnReloadMapset(self, event): """Reload selected mapset""" node = self.selected_mapset[0] - self._reloadMapsetNode(node) - self.RefreshNode(node, recursive=True) + self._reloadMapsetNodeWithExpandedStds(node) self.ExpandNode(node, recursive=False) def OnCopyMapsetPath(self, event): @@ -2141,6 +2536,165 @@ def OnCopyLocationPath(self, event): wx.TheClipboard.SetData(do) wx.TheClipboard.Close() + def OnRegisterStds(self, event): + """Launch t.register dialog pre-filled with selected dataset""" + stds_node = self.selected_stds[0] + mapset_node = self.selected_mapset[0] + stds_type = STDS_TO_MAP_TYPE[stds_node.data["type"]] + + self._giface.RunCmd( + [ + "t.register", + "--ui", + f"input={stds_node.data['name']}@{mapset_node.data['name']}", + f"type={stds_type}", + ], + onDone=lambda e: wx.CallAfter( + lambda: ( + self._reloadDatasetNode(stds_node, mapset_node), + self.RefreshNode(stds_node, recursive=True), + ) + ), + ) + + def OnUnregisterStds(self, event): + """Launch t.unregister dialog pre-filled with selected dataset""" + stds_node = self.selected_stds[0] + mapset_node = self.selected_mapset[0] + stds_type = STDS_TO_MAP_TYPE[stds_node.data["type"]] + + self._giface.RunCmd( + [ + "t.unregister", + "--ui", + f"input={stds_node.data['name']}@{mapset_node.data['name']}", + f"type={stds_type}", + ], + onDone=lambda e: wx.CallAfter( + lambda: ( + self._reloadDatasetNode(stds_node, mapset_node), + self.RefreshNode(stds_node, recursive=True), + ) + ), + ) + + def OnUnregisterStdsMap(self, event): + """Unregister selected maps from their temporal datasets natively""" + stds_groups = {} + for map_node in self.selected_stds_map: + stds_node = map_node.parent + if stds_node not in stds_groups: + stds_groups[stds_node] = [] + stds_groups[stds_node].append(map_node) + + num_maps = len(self.selected_stds_map) + if num_maps == 1: + question = _("Do you really want to unregister map <{m}>?").format( + m=self.selected_stds_map[0].data["name"] + ) + else: + question = _("Do you really want to unregister {n} maps?").format( + n=num_maps + ) + + if self._confirmDialog(question, title=_("Unregister map")) != wx.ID_YES: + return + + if num_maps == 1: + msg = _("Unregistering map...") + else: + msg = _("Unregistering {c} maps...").format(c=num_maps) + self.showNotification.emit(message=msg) + + success_count = 0 + + for stds_node, map_nodes in stds_groups.items(): + mapset_node = stds_node.parent + location_node = mapset_node.parent + grassdb_node = location_node.parent + + mapset_name = mapset_node.data["name"] + stds_name = stds_node.data["name"] + map_type = map_nodes[0].data["type"] + + maps = ",".join([n.data["map_id"] for n in map_nodes]) + + gisrc, env = gs.create_environment( + grassdb_node.data["name"], + location_node.data["name"], + mapset_name, + ) + + unregistered, cmd = self._runCommand( + "t.unregister", + input=stds_name, + type=map_type, + maps=maps, + env=env, + ) + + gs.try_remove(gisrc) + + if unregistered == 0: + success_count += len(map_nodes) + for node in map_nodes: + self._model.RemoveNode(node) + self.RefreshNode(stds_node, recursive=True) + + if success_count > 0: + if success_count == 1: + msg = _("Successfully unregistered map") + else: + msg = _("Successfully unregistered {c} maps").format(c=success_count) + self.showNotification.emit(message=msg) + self.UnselectAll() + + def OnPlotTimeline(self, event): + """Launch g.gui.timeline for one or more selected datasets""" + inputs = [ + f"{n.data['name']}@{m.data['name']}" + for n, m in zip(self.selected_stds, self.selected_mapset, strict=True) + ] + self._giface.RunCmd(["g.gui.timeline", f"inputs={','.join(inputs)}"]) + + def OnModifyMetadata(self, event): + """Launch t.support dialog to modify dataset metadata""" + stds_node = self.selected_stds[0] + mapset_node = self.selected_mapset[0] + stds_type = stds_node.data["type"] + + self._giface.RunCmd( + [ + "t.support", + "--ui", + f"input={stds_node.data['name']}@{mapset_node.data['name']}", + f"type={stds_type}", + ] + ) + + def OnMergeStds(self, event): + """Launch t.merge dialog pre-filled with selected datasets""" + stds_types = {node.data["type"] for node in self.selected_stds} + if len(stds_types) > 1: + GMessage( + _("Cannot merge temporal datasets of different types."), parent=self + ) + return + + stds_type = list(stds_types)[0] + mapset_node = self.selected_mapset[0] + inputs = [ + f"{n.data['name']}@{m.data['name']}" + for n, m in zip(self.selected_stds, self.selected_mapset, strict=True) + ] + + self._giface.RunCmd( + ["t.merge", "--ui", f"inputs={','.join(inputs)}", f"type={stds_type}"], + onDone=lambda e: wx.CallAfter( + lambda: self._reloadMapsetNodeWithExpandedStds(mapset_node) + ), + ) + def OnReloadGrassdb(self, event): """Reload all mapsets in selected grass db""" node = self.selected_grassdb[0] @@ -2204,6 +2758,88 @@ def _isCurrent(self, genv): return currentGrassDb, currentLocation, currentMapset return True, True, True + def _popupMenuStds(self): + """Create dedicated popup menu for space time datasets""" + menu = Menu() + + is_active = self.selected_mapset[0] == self.current_mapset_node + + item = wx.MenuItem(menu, wx.ID_ANY, _("&Register maps")) + menu.AppendItem(item) + self.Bind(wx.EVT_MENU, self.OnRegisterStds, item) + item.Enable(is_active) + + item = wx.MenuItem(menu, wx.ID_ANY, _("&Unregister maps")) + menu.AppendItem(item) + self.Bind(wx.EVT_MENU, self.OnUnregisterStds, item) + item.Enable(is_active) + + item = wx.MenuItem(menu, wx.ID_ANY, _("Modify metadata")) + menu.AppendItem(item) + self.Bind(wx.EVT_MENU, self.OnModifyMetadata, item) + item.Enable(is_active) + + item = wx.MenuItem(menu, wx.ID_ANY, _("Plot &timeline")) + menu.AppendItem(item) + self.Bind(wx.EVT_MENU, self.OnPlotTimeline, item) + + item = wx.MenuItem(menu, wx.ID_ANY, _("&Rename dataset")) + menu.AppendItem(item) + self.Bind(wx.EVT_MENU, self.OnRenameStds, item) + + item = wx.MenuItem(menu, wx.ID_ANY, _("&Delete dataset")) + menu.AppendItem(item) + self.Bind(wx.EVT_MENU, self.OnDeleteStds, item) + + menu.AppendSeparator() + + item = wx.MenuItem(menu, wx.ID_ANY, _("Show &metadata")) + menu.AppendItem(item) + self.Bind(wx.EVT_MENU, self.OnMetadataStds, item) + + self.PopupMenu(menu) + menu.Destroy() + + def _popupMenuStdsMap(self): + """Create popup menu for maps inside a temporal dataset""" + menu = Menu() + genv = gisenv() + currentGrassDb, currentLocation, currentMapset = self._isCurrent(genv) + + item = wx.MenuItem(menu, wx.ID_ANY, _("Copy &name")) + menu.AppendItem(item) + self.Bind(wx.EVT_MENU, self.OnCopyName, item) + + if not isinstance(self._giface, StandaloneGrassInterface): + if all( + each.data["name"] == genv["LOCATION_NAME"] + for each in self.selected_location + ): + if len(self.selected_layer) > 1: + item = wx.MenuItem(menu, wx.ID_ANY, _("&Display layers")) + else: + item = wx.MenuItem(menu, wx.ID_ANY, _("&Display layer")) + menu.AppendItem(item) + self.Bind(wx.EVT_MENU, self.OnDisplayLayer, item) + + item = wx.MenuItem(menu, wx.ID_ANY, _("Show &metadata")) + menu.AppendItem(item) + self.Bind(wx.EVT_MENU, self.OnMetadata, item) + + menu.AppendSeparator() + + if len(self.selected_layer) > 1: + item = wx.MenuItem(menu, wx.ID_ANY, _("&Unregister maps")) + else: + item = wx.MenuItem(menu, wx.ID_ANY, _("&Unregister map")) + menu.AppendItem(item) + self.Bind(wx.EVT_MENU, self.OnUnregisterStdsMap, item) + if not currentMapset: + item.Enable(False) + + self.PopupMenu(menu) + menu.Destroy() + def _popupMenuLayer(self): """Create popup menu for layers""" menu = Menu() @@ -2267,6 +2903,12 @@ def _popupMenuMapset(self): genv = gisenv() currentGrassDb, currentLocation, currentMapset = self._isCurrent(genv) + item = wx.MenuItem(menu, wx.ID_ANY, _("Create &temporal dataset")) + menu.AppendItem(item) + self.Bind(wx.EVT_MENU, self.OnCreateStds, item) + if self.selected_mapset[0] != self.current_mapset_node: + item.Enable(False) + item = wx.MenuItem(menu, wx.ID_ANY, _("&Paste")) menu.AppendItem(item) self.Bind(wx.EVT_MENU, self.OnPasteMap, item) @@ -2295,7 +2937,7 @@ def _popupMenuMapset(self): if self._restricted: item.Enable(False) - item = wx.MenuItem(menu, wx.ID_ANY, _("Re&load maps")) + item = wx.MenuItem(menu, wx.ID_ANY, _("Re&load mapset")) menu.AppendItem(item) self.Bind(wx.EVT_MENU, self.OnReloadMapset, item) @@ -2331,7 +2973,7 @@ def _popupMenuLocation(self): if self._restricted: item.Enable(False) - item = wx.MenuItem(menu, wx.ID_ANY, _("Re&load maps")) + item = wx.MenuItem(menu, wx.ID_ANY, _("Re&load project")) menu.AppendItem(item) self.Bind(wx.EVT_MENU, self.OnReloadLocation, item) @@ -2400,7 +3042,7 @@ def _popupMenuGrassDb(self): if self._restricted: item.Enable(False) - item = wx.MenuItem(menu, wx.ID_ANY, _("Re&load maps")) + item = wx.MenuItem(menu, wx.ID_ANY, _("Re&load database")) menu.AppendItem(item) self.Bind(wx.EVT_MENU, self.OnReloadGrassdb, item) @@ -2462,6 +3104,45 @@ def _popupMenuMultipleMapsets(self): self.PopupMenu(menu) menu.Destroy() + def _popupMenuMultipleStds(self): + """Create popup menu for multiple selected space time datasets""" + menu = Menu() + + item = wx.MenuItem(menu, wx.ID_ANY, _("Plot &timeline")) + menu.AppendItem(item) + self.Bind(wx.EVT_MENU, self.OnPlotTimeline, item) + + try: + path_output = tools.g_mapsets(format="json", flags="p", quiet=True) + accessible = set(path_output["mapsets"]) + except ToolError as e: + Debug.msg(1, f"Failed to read search path: {e}") + accessible = set() + + curr_loc = ( + self.current_location_node.data["name"] + if self.current_location_node + else "" + ) + + # Must be same location (by name) AND in the search path + is_mergeable = all( + loc.data["name"] == curr_loc and m.data["name"] in accessible + for loc, m in zip(self.selected_location, self.selected_mapset, strict=True) + ) + + item = wx.MenuItem(menu, wx.ID_ANY, _("&Merge datasets")) + menu.AppendItem(item) + self.Bind(wx.EVT_MENU, self.OnMergeStds, item) + item.Enable(is_mergeable) + + item = wx.MenuItem(menu, wx.ID_ANY, _("&Delete datasets")) + menu.AppendItem(item) + self.Bind(wx.EVT_MENU, self.OnDeleteStds, item) + + self.PopupMenu(menu) + menu.Destroy() + def OnRemoveLabel(self, path): """Remove label for database.""" grassdbs = UserSettings.Get(group="datacatalog", key="grassdbs") or {}