diff --git a/gui/icons/grass/str3ds.png b/gui/icons/grass/str3ds.png
new file mode 100644
index 00000000000..06fa48125a2
Binary files /dev/null and b/gui/icons/grass/str3ds.png differ
diff --git a/gui/icons/grass/str3ds.svg b/gui/icons/grass/str3ds.svg
new file mode 100644
index 00000000000..78df1de560f
--- /dev/null
+++ b/gui/icons/grass/str3ds.svg
@@ -0,0 +1,79 @@
+
+
+
+
diff --git a/gui/icons/grass/strds.png b/gui/icons/grass/strds.png
new file mode 100644
index 00000000000..78206dd1bec
Binary files /dev/null and b/gui/icons/grass/strds.png differ
diff --git a/gui/icons/grass/strds.svg b/gui/icons/grass/strds.svg
new file mode 100644
index 00000000000..70f612252d3
--- /dev/null
+++ b/gui/icons/grass/strds.svg
@@ -0,0 +1,79 @@
+
+
+
+
diff --git a/gui/icons/grass/stvds.png b/gui/icons/grass/stvds.png
new file mode 100644
index 00000000000..4795179a59f
Binary files /dev/null and b/gui/icons/grass/stvds.png differ
diff --git a/gui/icons/grass/stvds.svg b/gui/icons/grass/stvds.svg
new file mode 100644
index 00000000000..0bdbba40738
--- /dev/null
+++ b/gui/icons/grass/stvds.svg
@@ -0,0 +1,91 @@
+
+
+
+
diff --git a/gui/wxpython/core/gconsole.py b/gui/wxpython/core/gconsole.py
index 3606727d871..8be241a40f4 100644
--- a/gui/wxpython/core/gconsole.py
+++ b/gui/wxpython/core/gconsole.py
@@ -42,6 +42,7 @@
from grass.pydispatch.signal import Signal
from grass.grassdb import history
+from grass.grassdb.data import stds_exists
from grass.grassdb.history import Status
from core import globalvar
@@ -740,11 +741,31 @@ def OnCmdRun(self, event):
)
event.Skip()
+ @staticmethod
+ def _getStdsTypeCandidates(task):
+ """Space time dataset types a generic stds parameter of a tool can be
+
+ The type option of the tool names either the dataset type
+ (G_OPT_STDS_TYPE) or the type of the maps it holds (G_OPT_MAP_TYPE),
+ which leaves a single type to look up instead of all three.
+ """
+ stds_types = ("strds", "stvds", "str3ds")
+ map_types = {"raster": "strds", "vector": "stvds", "raster_3d": "str3ds"}
+ option = task.get_param(value="type", raiseError=False)
+ value = (option.get("value") or option.get("default")) if option else None
+ if value in stds_types:
+ return (value,)
+ if value in map_types:
+ return (map_types[value],)
+ return stds_types
+
def OnCmdDone(self, event):
"""Command done (or aborted)
- Sends signal mapCreated if map is recognized in output
- parameters or for specific modules (as r.colors).
+ Sends signal mapCreated if map is recognized in output parameters
+ and signal grassdbChanged for maps and space time datasets, either
+ recognized in output parameters or for specific modules (as r.colors
+ or t.register) which modify their input.
"""
# Process results here
try:
@@ -814,15 +835,23 @@ def OnCmdDone(self, event):
return
name = task.get_name()
+ # Parameter prompts used by space time datasets. The generic "stds" one
+ # is used where a parameter accepts any of the three dataset types.
+ stds_prompts = {"stds", "strds", "stvds", "str3ds"}
+ stds_candidates = self._getStdsTypeCandidates(task)
for p in task.get_options()["params"]:
prompt = p.get("prompt", "")
- if prompt in {"raster", "vector", "raster_3d"} and p.get("value", None):
+ if prompt in {"raster", "vector", "raster_3d"} | stds_prompts and p.get(
+ "value", None
+ ):
if p.get("age", "old") == "new" or name in {
"r.colors",
"r3.colors",
"v.colors",
"v.proj",
"r.proj",
+ "t.register",
+ "t.unregister",
}:
# if multiple maps (e.g. r.series.interp), we need add each
if p.get("multiple", False):
@@ -837,18 +866,45 @@ def OnCmdDone(self, event):
for lname in lnames:
if "@" not in lname:
lname += "@" + gs.gisenv()["MAPSET"]
- if gs.find_file(lname, element=p.get("element"))["fullname"]:
- self.mapCreated.emit(
- name=lname, ltype=prompt, add=event.addLayer
+ element_name, element_mapset = lname.split("@", 1)
+
+ # The generic stds prompt does not say which type
+ # this dataset is, so it is resolved per dataset.
+ element = prompt
+ if prompt == "stds":
+ element = next(
+ (
+ stds_type
+ for stds_type in stds_candidates
+ if stds_exists(
+ element_name, stds_type, element_mapset
+ )
+ ),
+ None,
+ )
+ exists = element is not None
+ elif prompt in stds_prompts:
+ exists = stds_exists(element_name, prompt, element_mapset)
+ else:
+ exists = bool(
+ gs.find_file(lname, element=p.get("element"))[
+ "fullname"
+ ]
)
+
+ if exists:
+ if element not in stds_prompts:
+ self.mapCreated.emit(
+ name=lname, ltype=element, add=event.addLayer
+ )
gisenv = gs.gisenv()
self._giface.grassdbChanged.emit(
grassdb=gisenv["GISDBASE"],
location=gisenv["LOCATION_NAME"],
mapset=gisenv["MAPSET"],
action="new",
- map=lname.split("@")[0],
- element=prompt,
+ map=element_name,
+ element=element,
)
if name == "r.mask":
action = "new"
diff --git a/gui/wxpython/datacatalog/catalog.py b/gui/wxpython/datacatalog/catalog.py
index 59c5162d8ad..f0d5267f7f5 100644
--- a/gui/wxpython/datacatalog/catalog.py
+++ b/gui/wxpython/datacatalog/catalog.py
@@ -361,6 +361,20 @@ def OnImportMenu(self, event):
menu.AppendItem(item)
self.Bind(wx.EVT_MENU, lambda evt: self.GuiParseCommand("v.in.ascii"), item)
+ menu.AppendSeparator()
+
+ item = wx.MenuItem(
+ menu, wx.ID_ANY, _("Import space time raster dataset [t.rast.import]")
+ )
+ menu.AppendItem(item)
+ self.Bind(wx.EVT_MENU, lambda evt: self.GuiParseCommand("t.rast.import"), item)
+
+ item = wx.MenuItem(
+ menu, wx.ID_ANY, _("Import space time vector dataset [t.vect.import]")
+ )
+ menu.AppendItem(item)
+ self.Bind(wx.EVT_MENU, lambda evt: self.GuiParseCommand("t.vect.import"), item)
+
menu.AppendSeparator()
menu.AppendMenu(wx.ID_ANY, _("Link external data"), subMenu)
diff --git a/gui/wxpython/datacatalog/g.gui.datacatalog.md b/gui/wxpython/datacatalog/g.gui.datacatalog.md
index 26ac0c46a52..eaa09295693 100644
--- a/gui/wxpython/datacatalog/g.gui.datacatalog.md
+++ b/gui/wxpython/datacatalog/g.gui.datacatalog.md
@@ -13,6 +13,7 @@ Data Catalog allows you to:
- browse GRASS projects and mapsets in the current GIS directory
- browse GRASS 2D/3D raster and vector maps
+- browse and manage space-time datasets
- rename, copy, move and delete GRASS maps including reprojection
between different projects
- set labels for a GRASS database (directory with GRASS projects) with
diff --git a/gui/wxpython/datacatalog/toolbars.py b/gui/wxpython/datacatalog/toolbars.py
index 3111704779d..0808b703b49 100644
--- a/gui/wxpython/datacatalog/toolbars.py
+++ b/gui/wxpython/datacatalog/toolbars.py
@@ -70,6 +70,12 @@ def __init__(self, parent, filter_function):
self.Bind(wx.EVT_MENU, self.OnFilterMenu, item)
item = filterMenu.AppendRadioItem(-1, "3D raster maps")
self.Bind(wx.EVT_MENU, self.OnFilterMenu, item)
+ item = filterMenu.AppendRadioItem(-1, "STRDS (space time raster datasets)")
+ self.Bind(wx.EVT_MENU, self.OnFilterMenu, item)
+ item = filterMenu.AppendRadioItem(-1, "STVDS (space time vector datasets)")
+ self.Bind(wx.EVT_MENU, self.OnFilterMenu, item)
+ item = filterMenu.AppendRadioItem(-1, "STR3DS (space time 3D raster datasets)")
+ self.Bind(wx.EVT_MENU, self.OnFilterMenu, item)
self.SetMenu(filterMenu)
helpTip = _(
"Type to search database by map type or name. "
@@ -87,6 +93,12 @@ def OnFilterMenu(self, event):
self.filter_element = "vector"
elif filterMenu[3].IsChecked():
self.filter_element = "raster_3d"
+ elif filterMenu[4].IsChecked():
+ self.filter_element = "strds"
+ elif filterMenu[5].IsChecked():
+ self.filter_element = "stvds"
+ elif filterMenu[6].IsChecked():
+ self.filter_element = "str3ds"
# trigger filter on change
if self.GetValue():
self.filter_function(self.GetValue(), self.filter_element)
diff --git a/gui/wxpython/datacatalog/tree.py b/gui/wxpython/datacatalog/tree.py
index dca1043ba16..ba65231ded4 100644
--- a/gui/wxpython/datacatalog/tree.py
+++ b/gui/wxpython/datacatalog/tree.py
@@ -24,6 +24,7 @@
import copy
from multiprocessing import Process, Queue
from pathlib import Path
+import importlib.util
import wx
@@ -40,8 +41,10 @@
from gui_core.dialogs import TextEntryDialog
from core.giface import StandaloneGrassInterface
from core.treemodel import TreeModel, DictFilterNode
+from grass.script.utils import naturally_sort
from gui_core.treeview import TreeView
from gui_core.wrap import Menu
+from gui_core.forms import GUI
from datacatalog.dialogs import CatalogReprojectionDialog
from icons.icon import MetaIcon
from core.settings import UserSettings
@@ -67,7 +70,8 @@
import grass.script as gs
from grass.script import gisenv
-from grass.grassdb.data import map_exists
+from grass.tools import Tools, ToolError
+from grass.grassdb.data import map_exists, stds_exists
from grass.grassdb.checks import (
get_mapset_owner,
is_mapset_locked,
@@ -76,10 +80,18 @@
)
from grass.exceptions import CalledModuleError
+STDS_TYPES = {"strds", "stvds", "str3ds"}
+STDS_TO_MAP_TYPE = {"strds": "raster", "stvds": "vector", "str3ds": "raster_3d"}
+
+HAS_MATPLOTLIB = importlib.util.find_spec("matplotlib") is not None
+
def getLocationTree(gisdbase, location, queue, mapsets=None, lazy=False):
- """Creates dictionary with mapsets, elements, layers for given location.
- Returns tuple with the dictionary and error (or None)"""
+ """Creates a dictionary containing mapsets, temporal datasets, data elements,
+ and layers for a given location.
+
+ Returns a tuple with the dictionary and an error message (or None if successful).
+ """
tmp_gisrc_file, env = gs.create_environment(gisdbase, location, "PERMANENT")
env["GRASS_SKIP_MAPSET_OWNER_CHECK"] = "1"
@@ -134,6 +146,79 @@ def getLocationTree(gisdbase, location, queue, mapsets=None, lazy=False):
name, mapset = wholename.split("@", maxsplit=1)
maps_dict[mapset].append({"name": name, "type": ltype})
+ try:
+ conns = (
+ Tools(env=env)
+ .t_connect(flags="p", format="json", mapset=",".join(mapsets), quiet=True)
+ .json
+ )
+ tgis_mapsets = ",".join(c["mapset"] for c in conns if c.get("driver"))
+
+ if tgis_mapsets:
+ try:
+ import grass.temporal as tgis
+ except ImportError as error:
+ # PyGRASS (ctypes) is the likely cause
+ queue.put(
+ (
+ maps_dict,
+ _(
+ "Unable to import the temporal framework: {e}\n"
+ "Space time datasets will not be listed"
+ ).format(e=error),
+ )
+ )
+ gs.try_remove(tmp_gisrc_file)
+ return
+
+ # grass.temporal takes the session from os.environ, so the temporary
+ # gisrc has to go there. This may be removed later when we have the
+ # option of passing env to tgis.init().
+ old_env = os.environ.copy()
+
+ try:
+ os.environ.update(env)
+ tgis.init(raise_fatal_error=True, skip_db_init=True)
+
+ dbif = tgis.SQLDatabaseInterfaceConnection(mapsets=tgis_mapsets)
+ dbif.connect()
+ try:
+ stds_by_mapset = tgis.tlist_grouped(
+ "stds", group_type=True, dbif=dbif
+ )
+ reg_maps = tgis.registered_maps_grouped(dbif=dbif)
+ finally:
+ dbif.close()
+ finally:
+ os.environ.clear()
+ os.environ.update(old_env)
+
+ for mapset, stds_by_type in stds_by_mapset.items():
+ if mapset not in maps_dict:
+ continue
+ maps_dict[mapset].extend(
+ {
+ "name": name,
+ "type": stds_type,
+ "registered_maps": reg_maps.get(stds_type, {}).get(
+ f"{name}@{mapset}", []
+ ),
+ }
+ for stds_type, names in stds_by_type.items()
+ for name in names
+ )
+ except Exception as error:
+ queue.put(
+ (
+ maps_dict,
+ _("Failed to read temporal datasets from project <{l}>: {e}").format(
+ l=location, e=error
+ ),
+ )
+ )
+ gs.try_remove(tmp_gisrc_file)
+ return
+
queue.put((maps_dict, None))
gs.try_remove(tmp_gisrc_file)
@@ -151,7 +236,26 @@ def OnOK(self, event):
new = self.GetValue()
if not new:
return
- if map_exists(new, self._element, env=self._env, mapset=self._mapset):
+ exists = False
+ if self._element in STDS_TYPES:
+ exists = stds_exists(new, self._element, env=self._env, mapset=self._mapset)
+ else:
+ exists = map_exists(new, self._element, env=self._env, mapset=self._mapset)
+
+ if exists:
+ if self._element in STDS_TYPES:
+ dlg = wx.MessageDialog(
+ self,
+ message=_(
+ "Space time dataset of type {elem} <{name}> already exists "
+ "in mapset <{mapset}>."
+ ).format(elem=self._element, name=new, mapset=self._mapset),
+ caption=_("Error"),
+ style=wx.OK | wx.ICON_ERROR,
+ )
+ dlg.ShowModal()
+ dlg.Destroy()
+ return
dlg = wx.MessageDialog(
self,
message=_(
@@ -228,6 +332,24 @@ def label(self):
return _("{name}").format(**data)
+class DataCatalogTreeModel(TreeModel):
+ """Tree model listing space time datasets before the individual maps."""
+
+ def SortChildren(self, node):
+ """Sort children naturally, space time datasets first
+
+ Natural sort compares the key as text, so the group a node belongs to
+ is expressed as a numeric prefix rather than as a separate sort field.
+ """
+ if node.children:
+
+ def sort_key(child):
+ group = "0" if child.data.get("type") in STDS_TYPES else "1"
+ return f"{group}_{child.label}"
+
+ naturally_sort(node.children, key=sort_key)
+
+
class DataCatalogTree(TreeView):
"""Tree structure visualizing and managing grass database.
Uses virtual tree and model defined in core/treemodel.py.
@@ -252,12 +374,13 @@ def __init__(
| wx.TR_MULTIPLE,
):
"""Location Map Tree constructor."""
- self._model = TreeModel(DataCatalogNode)
+ self._model = DataCatalogTreeModel(DataCatalogNode)
self._orig_model = self._model
super().__init__(parent=parent, model=self._model, id=wx.ID_ANY, style=style)
self._giface = giface
self._restricted = False
+ self.tools = Tools()
self.showNotification = Signal("Tree.showNotification")
self.showImportDataInfo = Signal("Tree.showImportDataInfo")
@@ -271,6 +394,9 @@ def __init__(
"grassdb",
"location",
"mapset",
+ "strds",
+ "stvds",
+ "str3ds",
"raster",
"vector",
"raster_3d",
@@ -345,6 +471,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
@@ -724,8 +852,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 the node representing a database, location, mapset, map, temporal dataset, or None if not found."""
grassdb_nodes = self._model.SearchNodes(name=grassdb, type="grassdb")
if grassdb_nodes:
if not location:
@@ -740,10 +870,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]
@@ -834,9 +964,78 @@ def ReloadCurrentMapset(self):
self._reloadMapsetNode(self.current_mapset_node)
self.RefreshNode(self.current_mapset_node, recursive=True)
+ @staticmethod
+ def _mapIdWithoutLayer(map_id):
+ """Return the map id as "name@mapset"
+
+ Vector maps registered in a space time dataset by layer have a
+ "name:layer@mapset" id, which is not the name of a map, so the
+ part after the colon is dropped.
+ """
+ name, mapset = map_id.split("@", 1)
+ return f"{name.split(':', 1)[0]}@{mapset}"
+
def _populateMapsetItem(self, mapset_node, data):
+ mapset_name = mapset_node.data["name"]
+ registered_map_ids = {
+ (self._mapIdWithoutLayer(r_map["id"]), STDS_TO_MAP_TYPE[item["type"]])
+ for item in data
+ if "registered_maps" in item
+ for r_map in item["registered_maps"]
+ }
+
for item in data:
- self._model.AppendNode(parent=mapset_node, data=dict(**item))
+ if (f"{item['name']}@{mapset_name}", item["type"]) in registered_map_ids:
+ continue
+
+ registered_maps = item.pop("registered_maps", [])
+ node = self._model.AppendNode(parent=mapset_node, data=dict(item))
+ if registered_maps:
+ child_type = STDS_TO_MAP_TYPE[item["type"]]
+ # A vector registered with several layers has one id per
+ # layer. Group the ids by map so that each map gets a single
+ # node, storing the per-layer ids t.unregister needs.
+ registered_ids = {}
+ for r_map in registered_maps:
+ registered_ids.setdefault(
+ self._mapIdWithoutLayer(r_map["id"]), []
+ ).append(r_map["id"])
+ for map_id, ids in registered_ids.items():
+ self._model.AppendNode(
+ parent=node,
+ data={
+ "type": child_type,
+ "name": map_id.split("@", 1)[0],
+ "map_id": map_id,
+ "registered_ids": ids,
+ },
+ )
+ self._model.SortChildren(mapset_node)
+
+ def _unhideMapsInMapset(self, mapset_node, map_ids, map_type):
+ """Add maps back to the map list of a mapset after unregistration
+
+ A registered map is listed under the datasets it belongs to instead
+ of under the mapset, so it returns to the map list only once no
+ dataset of this mapset holds it anymore. Maps of other mapsets are
+ listed in their own mapset and are left alone.
+ """
+ mapset_name = mapset_node.data["name"]
+ # A raster and a vector of the same name share an id, so the type is
+ # part of the key, as it is when the maps are hidden.
+ still_registered = {
+ (child.data["map_id"], child.data["type"])
+ for node in mapset_node.children
+ if node.data["type"] in STDS_TYPES
+ for child in node.children
+ }
+ for map_id in map_ids:
+ name, map_mapset = map_id.split("@", 1)
+ if map_mapset != mapset_name or (map_id, map_type) in still_registered:
+ continue
+ self._model.AppendNode(
+ parent=mapset_node, data={"type": map_type, "name": name}
+ )
self._model.SortChildren(mapset_node)
def _initImages(self):
@@ -845,6 +1044,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 +1067,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 +1133,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 +1220,16 @@ 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 {
+ "mapset",
+ "location",
+ "grassdb",
+ }
+ | STDS_TYPES
+ ):
# expand/collapse location/mapset...
if self.IsNodeExpanded(node):
self.CollapseNode(node, recursive=False)
@@ -1056,11 +1306,15 @@ def OnGetItemFont(self, index):
font.SetWeight(wx.FONTWEIGHT_NORMAL)
return font
- def ExpandCurrentMapset(self):
- """Expand current mapset"""
+ def ExpandCurrentMapset(self, recursive=False):
+ """Expand current mapset
+
+ Maps registered in a space time dataset are one level deeper than
+ the map list of the mapset and need recursive to be reached.
+ """
if self.current_mapset_node:
self.Select(self.current_mapset_node, select=True)
- self.ExpandNode(self.current_mapset_node, recursive=True)
+ self.ExpandNode(self.current_mapset_node, recursive=recursive)
def SetRestriction(self, restrict):
self._restricted = restrict
@@ -1145,6 +1399,10 @@ 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"""
+ GUI(parent=self, giface=self._giface).ParseCommand(cmd=["t.create"])
+
def CreateLocation(self, grassdb_node):
"""
Creates new location interactively and adds it to the tree and switch
@@ -1193,6 +1451,51 @@ 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:
+ label = _("Renaming dataset <{old}> to <{new}>...").format(
+ old=old_name, new=new_name
+ )
+ 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
@@ -1505,7 +1808,7 @@ def callback(
)
if dlg.ShowModal() == wx.ID_CANCEL:
return
- self.ExpandNode(self.selected_mapset[0], recursive=True)
+ self.ExpandNode(self.selected_mapset[0], recursive=False)
self._resetCopyVariables()
def _onDoneReprojection(
@@ -1523,7 +1826,7 @@ def _onDoneReprojection(
self._removeMapAfterCopy(cLayer, cMapset, iEnv)
gs.try_remove(iGisrc)
gs.try_remove(oGisrc)
- self.ExpandNode(sMapset, recursive=True)
+ self.ExpandNode(sMapset, recursive=False)
def _removeMapAfterCopy(self, cLayer, cMapset, env):
removed, cmd = self._runCommand(
@@ -1611,51 +1914,170 @@ 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:
- question = _("Do you really want to delete map(s) <{m}>?").format(
+
+ if len(names) == 1:
+ question = _("Do you really want to delete map <{m}>?").format(m=names[0])
+ elif len(names) <= 10:
+ question = _("Do you really want to delete maps <{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)):
+
+ if self._confirmDialog(question, title=_("Delete map")) != wx.ID_YES:
+ return
+
+ if len(names) <= 10:
+ 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"],
+ )
+
+ 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 selected temporal datasets, keeping their registered maps"""
+ self._deleteStds(delete_maps=False)
+
+ def OnDeleteStdsAndMaps(self, event):
+ """Delete selected temporal datasets together with their registered maps"""
+ self._deleteStds(delete_maps=True)
+
+ def _deleteStds(self, delete_maps):
+ """Delete one or multiple temporal datasets using t.remove
+
+ Deleting a dataset always unregisters its maps from it. With
+ delete_maps they are removed from the mapset as well, otherwise they
+ stay there and reappear in the map list of the mapset.
+ """
+ names = [stds.data["name"] for stds in self.selected_stds]
+
+ if len(names) == 1:
+ question = _("Do you really want to delete dataset <{m}>?").format(
+ m=names[0]
+ )
+ elif len(names) <= 10:
+ question = _("Do you really want to delete datasets <{m}>?").format(
+ m=", ".join(names)
+ )
+ else:
+ question = _("Do you really want to delete {n} datasets?").format(
+ n=len(names)
+ )
+
+ if delete_maps:
+ title = _("Delete dataset and maps")
+ question += "\n\n" + (
+ _("All maps registered in the dataset will be deleted as well.")
+ if len(names) == 1
+ else _("All maps registered in the datasets will be deleted as well.")
+ )
+ else:
+ title = _("Delete dataset")
+
+ if self._confirmDialog(question, title=title) != wx.ID_YES:
+ return
+
+ self.showNotification.emit(message=_("Deleting datasets..."))
+ self.busy = wx.BusyCursor()
+
+ deleted = []
+ try:
+ 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,
+ ):
gisrc, env = gs.create_environment(
- self.selected_grassdb[i].data["name"],
- self.selected_location[i].data["name"],
- self.selected_mapset[i].data["name"],
+ grassdb_node.data["name"],
+ location_node.data["name"],
+ mapset_node.data["name"],
)
+
removed, cmd = self._runCommand(
- "g.remove",
- flags="f",
- type=self.selected_layer[i].data["type"],
- name=self.selected_layer[i].data["name"],
+ "t.remove",
+ inputs=stds_node.data["name"],
+ type=stds_node.data["type"],
+ flags="df" if delete_maps else "f",
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"
+ deleted.append(
+ (grassdb_node, location_node, mapset_node, stds_node)
)
- self.UnselectAll()
- self.showNotification.emit(message=_("g.remove completed"))
+ # Sent after all the removals, so that the mapset is read again
+ # once for the whole selection and not once per dataset.
+ for grassdb_node, location_node, mapset_node, stds_node in deleted:
+ self._giface.grassdbChanged.emit(
+ grassdb=grassdb_node.data["name"],
+ location=location_node.data["name"],
+ mapset=mapset_node.data["name"],
+ element=stds_node.data["type"],
+ map=stds_node.data["name"],
+ action="delete",
+ )
+ finally:
+ del self.busy
+
+ self.UnselectAll()
+ self.showNotification.emit(message=_("t.remove completed"))
+
+ def OnExportStds(self, event):
+ """Export a space time dataset"""
+ stds_node = self.selected_stds[0]
+ mapset_node = self.selected_mapset[0]
+ stds_type = stds_node.data["type"]
+
+ cmd = {"strds": "t.rast.export", "stvds": "t.vect.export"}.get(stds_type)
+ if cmd:
+ GUI(parent=self, giface=self._giface).ParseCommand(
+ cmd=[
+ cmd,
+ f"input={stds_node.data['name']}@{mapset_node.data['name']}",
+ ]
+ )
def OnDeleteMapset(self, event):
"""
@@ -1772,12 +2194,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 +2372,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 +2383,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._reloadMapsetNode(node.parent)
+ 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)
@@ -1999,14 +2453,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 +2469,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 +2521,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()
@@ -2082,7 +2551,10 @@ def Filter(self, text, element=None):
self.UpdateCurrentDbLocationMapsetNode()
self.RefreshItems()
- self.ExpandCurrentMapset()
+ # A match can be a map registered in a space time dataset, which is
+ # not reached by expanding the mapset alone. Expanding everything
+ # below the mapset is only affordable while a filter is active.
+ self.ExpandCurrentMapset(recursive=bool(name))
if self._model.GetLeafCount(self._model.root) <= 50:
self.ExpandAll()
@@ -2141,6 +2613,161 @@ 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]
+ map_type = STDS_TO_MAP_TYPE[stds_node.data["type"]]
+
+ GUI(parent=self, giface=self._giface).ParseCommand(
+ cmd=[
+ "t.register",
+ f"input={stds_node.data['name']}@{mapset_node.data['name']}",
+ f"type={map_type}",
+ ]
+ )
+
+ 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]
+ map_type = STDS_TO_MAP_TYPE[stds_node.data["type"]]
+
+ GUI(parent=self, giface=self._giface).ParseCommand(
+ cmd=[
+ "t.unregister",
+ f"input={stds_node.data['name']}@{mapset_node.data['name']}",
+ f"type={map_type}",
+ ]
+ )
+
+ 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
+ mapsets_to_refresh = set()
+
+ 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"]
+
+ # A vector shown as one node can be registered with several
+ # layers, and each of those ids has to be unregistered.
+ maps = ",".join(
+ registered_id
+ for node in map_nodes
+ for registered_id in node.data["registered_ids"]
+ )
+
+ 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)
+ # The unregistered maps are known here, so the tree is
+ # updated in place instead of reloading the mapset again.
+ for map_node in map_nodes:
+ self._model.RemoveNode(map_node)
+ self._unhideMapsInMapset(
+ mapset_node, [n.data["map_id"] for n in map_nodes], map_type
+ )
+ mapsets_to_refresh.add(mapset_node)
+
+ if success_count > 0:
+ for m_node in mapsets_to_refresh:
+ self.RefreshNode(m_node, recursive=True)
+
+ 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 OnDisplayTemporalExtent(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 OnUpdateMetadata(self, event):
+ """Launch t.support dialog to update dataset metadata"""
+ stds_node = self.selected_stds[0]
+ mapset_node = self.selected_mapset[0]
+ stds_type = stds_node.data["type"]
+
+ GUI(parent=self, giface=self._giface).ParseCommand(
+ cmd=[
+ "t.support",
+ 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]
+ inputs = [
+ f"{n.data['name']}@{m.data['name']}"
+ for n, m in zip(self.selected_stds, self.selected_mapset, strict=True)
+ ]
+
+ GUI(parent=self, giface=self._giface).ParseCommand(
+ cmd=["t.merge", f"inputs={','.join(inputs)}", f"type={stds_type}"]
+ )
+
def OnReloadGrassdb(self, event):
"""Reload all mapsets in selected grass db"""
node = self.selected_grassdb[0]
@@ -2204,6 +2831,140 @@ def _isCurrent(self, genv):
return currentGrassDb, currentLocation, currentMapset
return True, True, True
+ def _getMapsetsInSearchPath(self):
+ """Names of the mapsets in the current search path
+
+ Tools reading a dataset (t.merge, g.gui.timeline) only see datasets
+ of these mapsets. Returns an empty set when the search path cannot
+ be read, which disables those tools rather than letting them fail.
+ """
+ try:
+ return set(
+ self.tools.g_mapsets(format="json", flags="p", quiet=True)["mapsets"]
+ )
+ except ToolError:
+ return set()
+
+ def _selectedStdsAreAccessible(self):
+ """Whether the current session can read all the selected datasets
+
+ Tools started without an environment (t.merge, g.gui.timeline,
+ t.rast.export) run in the current project and see only the mapsets
+ of its search path. The mapset name alone does not decide this,
+ because mapset names can repeat across projects.
+ """
+ genv = gisenv()
+ accessible = self._getMapsetsInSearchPath()
+ return all(
+ grassdb.data["name"] == genv["GISDBASE"]
+ and location.data["name"] == genv["LOCATION_NAME"]
+ and mapset.data["name"] in accessible
+ for grassdb, location, mapset in zip(
+ self.selected_grassdb,
+ self.selected_location,
+ self.selected_mapset,
+ strict=True,
+ )
+ )
+
+ def _popupMenuStds(self):
+ """Create dedicated popup menu for space time datasets"""
+ menu = Menu()
+
+ is_active = self.selected_mapset[0] == self.current_mapset_node
+ is_accessible = self._selectedStdsAreAccessible()
+
+ 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, _("Update metadata"))
+ menu.AppendItem(item)
+ self.Bind(wx.EVT_MENU, self.OnUpdateMetadata, item)
+ item.Enable(is_active)
+
+ item = wx.MenuItem(menu, wx.ID_ANY, _("Display &temporal extent"))
+ menu.AppendItem(item)
+ self.Bind(wx.EVT_MENU, self.OnDisplayTemporalExtent, item)
+ item.Enable(HAS_MATPLOTLIB and is_accessible)
+
+ 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 only"))
+ menu.AppendItem(item)
+ self.Bind(wx.EVT_MENU, self.OnDeleteStds, item)
+
+ item = wx.MenuItem(menu, wx.ID_ANY, _("Delete dataset and maps"))
+ menu.AppendItem(item)
+ self.Bind(wx.EVT_MENU, self.OnDeleteStdsAndMaps, item)
+
+ item = wx.MenuItem(menu, wx.ID_ANY, _("&Export dataset"))
+ menu.AppendItem(item)
+ self.Bind(wx.EVT_MENU, self.OnExportStds, item)
+ stds_type = self.selected_stds[0].data["type"]
+ item.Enable(is_accessible and stds_type in {"strds", "stvds"})
+
+ 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"))
+ menu.AppendItem(item)
+ self.Bind(wx.EVT_MENU, self.OnCopyMap, item)
+
+ 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 +3028,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 +3062,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 +3098,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 +3167,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 +3229,33 @@ def _popupMenuMultipleMapsets(self):
self.PopupMenu(menu)
menu.Destroy()
+ def _popupMenuMultipleStds(self):
+ """Create popup menu for multiple selected space time datasets"""
+ menu = Menu()
+
+ is_accessible = self._selectedStdsAreAccessible()
+
+ item = wx.MenuItem(menu, wx.ID_ANY, _("Display &temporal extent"))
+ menu.AppendItem(item)
+ self.Bind(wx.EVT_MENU, self.OnDisplayTemporalExtent, item)
+ item.Enable(HAS_MATPLOTLIB and is_accessible)
+
+ item = wx.MenuItem(menu, wx.ID_ANY, _("&Merge datasets"))
+ menu.AppendItem(item)
+ self.Bind(wx.EVT_MENU, self.OnMergeStds, item)
+ item.Enable(is_accessible)
+
+ item = wx.MenuItem(menu, wx.ID_ANY, _("&Delete datasets only"))
+ menu.AppendItem(item)
+ self.Bind(wx.EVT_MENU, self.OnDeleteStds, item)
+
+ item = wx.MenuItem(menu, wx.ID_ANY, _("Delete datasets and maps"))
+ menu.AppendItem(item)
+ self.Bind(wx.EVT_MENU, self.OnDeleteStdsAndMaps, item)
+
+ self.PopupMenu(menu)
+ menu.Destroy()
+
def OnRemoveLabel(self, path):
"""Remove label for database."""
grassdbs = UserSettings.Get(group="datacatalog", key="grassdbs") or {}
diff --git a/python/grass/grassdb/data.py b/python/grass/grassdb/data.py
index 498fbc1314a..f56d1ce8d36 100644
--- a/python/grass/grassdb/data.py
+++ b/python/grass/grassdb/data.py
@@ -10,6 +10,7 @@
"""
import grass.script as gs
+from grass.tools import Tools, ToolError
def map_exists(name, element, mapset=None, env=None) -> bool:
@@ -43,3 +44,29 @@ def map_exists(name, element, mapset=None, env=None) -> bool:
# file is the key questioned in grass.script.core find_file()
# return code should be equivalent to checking the output
return bool(info["file"])
+
+
+def stds_exists(name, element, mapset, env=None) -> bool:
+ """Check if a space time dataset (STDS) is present in the temporal database.
+
+ :param name: Name of the space time dataset
+ :param element: STDS type ('strds', 'stvds', 'str3ds')
+ :param mapset: Mapset name where the dataset is located
+ :param env: Environment created by function grass.script.create_environment
+ """
+ # The where clause goes to SQL as it is, and a quote inside a SQL string
+ # literal has to be written twice to not end the literal early.
+ escaped_name = name.replace("'", "''")
+ tools = Tools(env=env)
+ try:
+ output = tools.t_list(
+ type=element,
+ columns="name",
+ where=f"name='{escaped_name}'",
+ mapset=mapset,
+ format="json",
+ quiet=True,
+ )
+ return bool(output.json)
+ except ToolError:
+ return False
diff --git a/python/grass/temporal/gui_support.py b/python/grass/temporal/gui_support.py
index 1cd32be5b75..a0020569875 100644
--- a/python/grass/temporal/gui_support.py
+++ b/python/grass/temporal/gui_support.py
@@ -10,6 +10,8 @@
:authors: Soeren Gebbert
"""
+from datetime import datetime
+
import grass.script as gs
from grass.exceptions import ScriptError
@@ -188,9 +190,15 @@ def registered_maps_grouped(dbif=None):
# A dataset is homogeneous in temporal type, so its start times are
# mutually comparable; maps without a start time are listed last.
+ # A dataset holding both absolute and relative time maps is ordered by
+ # absolute time first, then relative time, then maps without a start time.
def sort_key(map_info):
start = map_info["start_time"]
- return (start is None, 0 if start is None else start)
+ if start is None:
+ return (2, 0)
+ if isinstance(start, datetime):
+ return (0, start)
+ return (1, start)
for datasets in result.values():
for maps in datasets.values():