diff --git a/gui/wxpython/core/treemodel.py b/gui/wxpython/core/treemodel.py index 46f255021e9..4191f0cc6c8 100644 --- a/gui/wxpython/core/treemodel.py +++ b/gui/wxpython/core/treemodel.py @@ -240,6 +240,52 @@ def match(self, key, value): return False +class DictFilterNode(DictNode): + """Node which has data in a form of dictionary and can be filtered.""" + + def __init__(self, data=None): + super().__init__(data=data) + + def match(self, method="exact", **kwargs): + """Method used for searching according to given parameters. + + :param str method: 'exact' for exact match or + 'filtering' for filtering by type/name + :param kwargs key-value to be matched, filtering method uses 'type' and 'name' + :return bool: True if an entry matching given parameters was found + """ + if not kwargs: + return False + + if method == "exact": + return self._match_exact(**kwargs) + elif method == "filtering": + return self._match_filtering(**kwargs) + + def _match_exact(self, **kwargs): + """Match method for exact matching.""" + for key, value in kwargs.items(): + if not (key in self.data and self.data[key] == value): + return False + return True + + def _match_filtering(self, **kwargs): + """Match method for filtering.""" + if ( + "type" in kwargs + and "type" in self.data + and kwargs["type"] != self.data["type"] + ): + return False + if ( + "name" in kwargs + and "name" in self.data + and not kwargs["name"].search(self.data["name"]) + ): + return False + return True + + class ModuleNode(DictNode): """Node representing module.""" diff --git a/gui/wxpython/datacatalog/tree.py b/gui/wxpython/datacatalog/tree.py index 8821aeb61d4..bd479e69bc5 100644 --- a/gui/wxpython/datacatalog/tree.py +++ b/gui/wxpython/datacatalog/tree.py @@ -38,7 +38,7 @@ ) from gui_core.dialogs import TextEntryDialog from core.giface import StandaloneGrassInterface -from core.treemodel import TreeModel, DictNode +from core.treemodel import TreeModel, DictFilterNode from gui_core.treeview import TreeView from gui_core.wrap import Menu from datacatalog.dialogs import CatalogReprojectionDialog @@ -171,7 +171,7 @@ def OnOK(self, event): self.EndModal(wx.ID_OK) -class DataCatalogNode(DictNode): +class DataCatalogNode(DictFilterNode): """Node representing item in datacatalog.""" def __init__(self, data=None): @@ -197,36 +197,6 @@ def label(self): return _("{name}").format(**data) - def match(self, method="exact", **kwargs): - """Method used for searching according to given parameters. - - :param method: 'exact' for exact match or 'filtering' for filtering by type/name - :param kwargs key-value to be matched, filtering method uses 'type' and 'name' - where 'name' is compiled regex - """ - if not kwargs: - return False - - if method == "exact": - for key, value in kwargs.items(): - if not (key in self.data and self.data[key] == value): - return False - return True - # for filtering - if ( - "type" in kwargs - and "type" in self.data - and kwargs["type"] != self.data["type"] - ): - return False - if ( - "name" in kwargs - and "name" in self.data - and not kwargs["name"].search(self.data["name"]) - ): - return False - return True - class DataCatalogTree(TreeView): """Tree structure visualizing and managing grass database. diff --git a/gui/wxpython/history/tree.py b/gui/wxpython/history/tree.py index cd67dd5fe90..caaeb8bda86 100644 --- a/gui/wxpython/history/tree.py +++ b/gui/wxpython/history/tree.py @@ -19,6 +19,7 @@ import re import copy +import datetime import wx @@ -31,7 +32,7 @@ split, ) from gui_core.forms import GUI -from core.treemodel import TreeModel, ModuleNode +from core.treemodel import TreeModel, DictFilterNode from gui_core.treeview import CTreeView from gui_core.wrap import Menu @@ -40,6 +41,46 @@ from grass.grassdb import history +# global variable for purposes of sorting "No time info" node +OLD_DATE = datetime.datetime(1950, 1, 1).date() + + +class HistoryBrowserNode(DictFilterNode): + """Node representing item in history browser.""" + + def __init__(self, data=None): + super().__init__(data=data) + + @property + def label(self): + if "day" in self.data.keys(): + return self.dayToLabel(self.data["day"]) + else: + return self.data["name"] + + def dayToLabel(self, day): + """ + Convert day (midnight timestamp) to a day node label. + :param day datetime.date object: midnight of a day. + :return str: Corresponding day label. + """ + current_date = datetime.date.today() + + if day == OLD_DATE: + return _("No time info") + + if day == current_date: + return "{:%B %-d} (today)".format(day) + elif day == current_date - datetime.timedelta(days=1): + return "{:%B %-d} (yesterday)".format(day) + elif day >= (current_date - datetime.timedelta(days=current_date.weekday())): + return "{:%B %-d} (this week)".format(day) + elif day.year == current_date.year: + return "{:%B %-d}".format(day) + else: + return "{:%B %-d, %Y}".format(day) + + class HistoryBrowserTree(CTreeView): """Tree structure visualizing and managing history of executed commands. Uses virtual tree and model defined in core/treemodel.py. @@ -57,7 +98,7 @@ def __init__( | wx.TR_FULL_ROW_HIGHLIGHT, ): """History Browser Tree constructor.""" - self._model = TreeModel(ModuleNode) + self._model = TreeModel(HistoryBrowserNode) self._orig_model = self._model super().__init__(parent=parent, model=self._model, id=wx.ID_ANY, style=style) @@ -65,6 +106,8 @@ def __init__( self.parent = parent self.infoPanel = infoPanel + self._resetSelectVariables() + self._initHistoryModel() self.showNotification = Signal("HistoryBrowserTree.showNotification") @@ -72,43 +115,34 @@ def __init__( self._giface.currentMapsetChanged.connect(self.UpdateHistoryModelFromScratch) self._giface.entryToHistoryAdded.connect( - lambda entry: self.AppendNodeToHistoryModel(entry) + lambda entry: self.InsertCommand(entry) ) self._giface.entryInHistoryUpdated.connect( - lambda entry: self.UpdateNodeInHistoryModel(entry) + lambda entry: self.UpdateCommand(entry) ) self.SetToolTip(_("Double-click to open the tool")) self.selectionChanged.connect(self.OnItemSelected) - self.itemActivated.connect(lambda node: self.Run(node)) + self.itemActivated.connect(self.OnDoubleClick) self.contextMenu.connect(self.OnRightClick) - def _initHistoryModel(self): - """Fill tree history model based on the current history log.""" - try: - history_path = history.get_current_mapset_gui_history_path() - content_list = history.read(history_path) - except (OSError, ValueError) as e: - GError(str(e)) - - for data in content_list: - self._model.AppendNode( - parent=self._model.root, - label=data["command"].strip(), - data=data, + def _sortDays(self): + """Sort day nodes from earliest to oldest.""" + if self._model.root.children: + self._model.root.children.sort( + key=lambda node: node.data["day"], reverse=True ) - self._refreshTree() def _refreshTree(self): """Refresh tree models""" + self._sortDays() self.SetModel(copy.deepcopy(self._model)) self._orig_model = self._model - def _getSelectedNode(self): - selection = self.GetSelected() - if not selection: - return None - return selection[0] + def _resetSelectVariables(self): + """Reset variables related to item selection.""" + self.selected_day = [] + self.selected_command = [] def _confirmDialog(self, question, title): """Confirm dialog""" @@ -117,7 +151,7 @@ def _confirmDialog(self, question, title): dlg.Destroy() return res - def _popupMenuLayer(self): + def _popupMenuCommand(self): """Create popup menu for commands""" menu = Menu() @@ -128,15 +162,173 @@ def _popupMenuLayer(self): self.PopupMenu(menu) menu.Destroy() + def _popupMenuEmpty(self): + """Create empty popup when multiple different types of items are selected""" + menu = Menu() + item = wx.MenuItem(menu, wx.ID_ANY, _("No available options")) + menu.AppendItem(item) + item.Enable(False) + self.PopupMenu(menu) + menu.Destroy() + + def _timestampToDay(self, timestamp=None): + """ + Convert timestamp to datetime.date object with time set to midnight. + :param str timestamp: Timestamp as a string in ISO format. + :return datetime.date day_midnight: midnight of a day. + """ + if not timestamp: + return OLD_DATE + + timestamp_datetime = datetime.datetime.fromisoformat(timestamp) + day_midnight = datetime.datetime( + timestamp_datetime.year, timestamp_datetime.month, timestamp_datetime.day + ).date() + + return day_midnight + + def _initHistoryModel(self): + """Fill tree history model based on the current history log.""" + content_list = self.ReadFromHistory() + + for entry in content_list: + timestamp = None + if entry["command_info"]: + # Find day node for entries with command info + timestamp = entry["command_info"].get("timestamp") + if timestamp: + day = self._model.SearchNodes( + parent=self._model.root, + day=self._timestampToDay(timestamp), + type="day", + ) + else: + # Find day node prepared for entries without any command info + day = self._model.SearchNodes( + parent=self._model.root, + day=self._timestampToDay(), + type="day", + ) + + if day: + day = day[0] + else: + # Create time period node if not found + if not entry["command_info"]: + # Prepare it for entries without command info + day = self._model.AppendNode( + parent=self._model.root, + data=dict(type="day", day=self._timestampToDay()), + ) + else: + day = self._model.AppendNode( + parent=self._model.root, + data=dict( + type="day", + day=self._timestampToDay( + entry["command_info"]["timestamp"] + ), + ), + ) + + # Determine status and create command node + status = ( + entry["command_info"].get("status") + if entry.get("command_info") + and entry["command_info"].get("status") is not None + else "unknown" + ) + + # Add command to time period node + self._model.AppendNode( + parent=day, + data=dict( + type="command", + name=entry["command"].strip(), + timestamp=timestamp if timestamp else None, + status=status, + ), + ) + + # Refresh the tree view + self._refreshTree() + + def _getIndexFromFile(self, command_node): + """Get index of command node in the corresponding history log file.""" + if not command_node.data["timestamp"]: + return self._model.GetIndexOfNode(command_node)[1] + else: + return history.filter( + json_data=self.ReadFromHistory(), + command=command_node.data["name"], + timestamp=command_node.data["timestamp"], + ) + + def ReadFromHistory(self): + """Read content of command history log. + It is a wrapper which considers GError. + """ + try: + history_path = history.get_current_mapset_gui_history_path() + content_list = history.read(history_path) + except (OSError, ValueError) as e: + GError(str(e)) + return content_list + + def RemoveEntryFromHistory(self, index): + """Remove entry from command history log. + It is a wrapper which considers GError. + + :param int index: index of the entry which should be removed + """ + try: + history_path = history.get_current_mapset_gui_history_path() + history.remove_entry(history_path, index) + except (OSError, ValueError) as e: + GError(str(e)) + + def GetCommandInfo(self, index): + """Get command info for the given command index. + It is a wrapper which considers GError. + + :param int index: index of the command + """ + command_info = {} + try: + history_path = history.get_current_mapset_gui_history_path() + command_info = history.read(history_path)[index]["command_info"] + except (OSError, ValueError) as e: + GError(str(e)) + return command_info + + def DefineItems(self, selected): + """Set selected items.""" + self._resetSelectVariables() + for item in selected: + type = item.data["type"] + if type == "command": + self.selected_command.append(item) + self.selected_day.append(item.parent) + elif type == "day": + self.selected_command.append(None) + self.selected_day.append(item) + def Filter(self, text): """Filter history :param str text: text string """ if text: - self._model = self._orig_model.Filtered(key=["command"], value=text) + try: + compiled = re.compile(text) + except re.error: + return + self._model = self._orig_model.Filtered( + method="filtering", name=compiled, type="command" + ) else: self._model = self._orig_model self.RefreshItems() + self.ExpandAll() def UpdateHistoryModelFromScratch(self): """Reload tree history model based on the current history log from scratch.""" @@ -144,110 +336,172 @@ def UpdateHistoryModelFromScratch(self): self._initHistoryModel() self.infoPanel.clearCommandInfo() - def AppendNodeToHistoryModel(self, entry): - """Append node to the model and refresh the tree. + def InsertCommand(self, entry): + """Insert command node to the model and refresh the tree. :param entry dict: entry with 'command' and 'command_info' keys """ - new_node = self._model.AppendNode( - parent=self._model.root, - label=entry["command"].strip(), - data=entry, + # Check if today time period node exists or create it + today = self._timestampToDay(entry["command_info"]["timestamp"]) + today_nodes = self._model.SearchNodes( + parent=self._model.root, day=today, type="day" ) + if not today_nodes: + today_node = self._model.AppendNode( + parent=self._model.root, + data=dict( + type="day", + day=today, + ), + ) + else: + today_node = today_nodes[0] + + # Create the command node under today time period node + command_node = self._model.AppendNode( + parent=today_node, + data=dict( + type="command", + name=entry["command"].strip(), + timestamp=entry["command_info"]["timestamp"], + status=entry["command_info"].get("status", "in process"), + ), + ) + + # Refresh the tree self._refreshTree() - self.Select(new_node) - self.ExpandNode(new_node) + + # Select and expand the newly added command node + self.Select(command_node) + self.ExpandNode(command_node) + + # Show command info in info panel self.infoPanel.showCommandInfo(entry["command_info"]) - def UpdateNodeInHistoryModel(self, entry): + def UpdateCommand(self, entry): """Update last node in the model and refresh the tree. :param entry dict: entry with 'command' and 'command_info' keys """ + # Get node of last command + today = self._timestampToDay(entry["command_info"]["timestamp"]) + today_node = self._model.SearchNodes( + parent=self._model.root, day=today, type="day" + )[0] + command_nodes = self._model.SearchNodes(parent=today_node, type="command") + last_node = command_nodes[-1] + # Remove last node - index = [self._model.GetLeafCount(self._model.root) - 1] - tree_node = self._model.GetNodeByIndex(index) - self._model.RemoveNode(tree_node) + self._model.RemoveNode(last_node) - # Add new node to the model - self.AppendNodeToHistoryModel(entry) + # Add new command node to the model + self.InsertCommand(entry) def Run(self, node=None): """Parse selected history command into list and launch module dialog.""" - node = node or self._getSelectedNode() - if node: - command = node.data["command"] - lst = re.split(r"\s+", command) - if ( - globalvar.ignoredCmdPattern - and re.compile(globalvar.ignoredCmdPattern).search(command) - and "--help" not in command - and "--ui" not in command - ): - self.runIgnoredCmdPattern.emit(cmd=lst) - self.runIgnoredCmdPattern.emit(cmd=split(command)) - return - if re.compile(r"^r[3]?\.mapcalc").search(command): - command = parse_mapcalc_cmd(command) - command = replace_module_cmd_special_flags(command) - lst = split(command) - try: - GUI(parent=self, giface=self._giface).ParseCommand(lst) - except GException as e: - GError( - parent=self, - message=str(e), - caption=_("Cannot be parsed into command"), - showTraceback=False, - ) + if not node: + node = self.GetSelected() + self.DefineItems(node) + + if not self.selected_command: + return + + selected_command = self.selected_command[0] + command = selected_command.data["name"] + + lst = re.split(r"\s+", command) + if ( + globalvar.ignoredCmdPattern + and re.compile(globalvar.ignoredCmdPattern).search(command) + and "--help" not in command + and "--ui" not in command + ): + self.runIgnoredCmdPattern.emit(cmd=lst) + self.runIgnoredCmdPattern.emit(cmd=split(command)) + return + if re.compile(r"^r[3]?\.mapcalc").search(command): + command = parse_mapcalc_cmd(command) + command = replace_module_cmd_special_flags(command) + lst = split(command) + try: + GUI(parent=self, giface=self._giface).ParseCommand(lst) + except GException as e: + GError( + parent=self, + message=str(e), + caption=_("Cannot be parsed into command"), + showTraceback=False, + ) - def RemoveEntryFromHistory(self, index): - """Remove entry from command history log. + def OnRemoveCmd(self, event): + """Remove cmd from the history file""" + self.DefineItems(self.GetSelected()) + if not self.selected_command: + return - :param int index: index of the entry which should be removed - """ - try: - history_path = history.get_current_mapset_gui_history_path() - history.remove_entry(history_path, index) - except (OSError, ValueError) as e: - GError(str(e)) + selected_command = self.selected_command[0] + selected_day = self.selected_day[0] + command = selected_command.data["name"] - def GetCommandInfo(self, index): - """Get command info for the given command index. + # Confirm deletion with user + question = _("Do you really want to remove <{}> command?").format(command) + if self._confirmDialog(question, title=_("Remove command")) != wx.ID_YES: + return - :param int index: index of the command - """ - command_info = {} - try: - history_path = history.get_current_mapset_gui_history_path() - command_info = history.read(history_path)[index]["command_info"] - except (OSError, ValueError) as e: - GError(str(e)) - return command_info + self.showNotification.emit(message=_("Removing <{}>").format(command)) - def OnRemoveCmd(self, event): - """Remove cmd from the history file""" - tree_node = self._getSelectedNode() - cmd = tree_node.data["command"] - question = _("Do you really want to remove <{}> command?").format(cmd) - if self._confirmDialog(question, title=_("Remove command")) == wx.ID_YES: - self.showNotification.emit(message=_("Removing <{}>").format(cmd)) - tree_index = self._model.GetIndexOfNode(tree_node)[0] - self.RemoveEntryFromHistory(tree_index) - self.infoPanel.clearCommandInfo() - self._giface.entryFromHistoryRemoved.emit(index=tree_index) - self._model.RemoveNode(tree_node) - self._refreshTree() - self.showNotification.emit(message=_("<{}> removed").format(cmd)) + # Find the index of the selected command in history file + history_index = self._getIndexFromFile(selected_command) + + # Remove the entry from history + self.RemoveEntryFromHistory(history_index) + self.infoPanel.clearCommandInfo() + self._giface.entryFromHistoryRemoved.emit(index=history_index) + self._model.RemoveNode(selected_command) + + # Check if the time period node should also be removed + selected_day = selected_command.parent + if selected_day and len(selected_day.children) == 0: + self._model.RemoveNode(selected_day) + + self._refreshTree() + self.showNotification.emit(message=_("<{}> removed").format(command)) def OnItemSelected(self, node): - """Item selected""" - command = node.data["command"] - self.showNotification.emit(message=command) - tree_index = self._model.GetIndexOfNode(node)[0] - command_info = self.GetCommandInfo(tree_index) + """Handle item selection in the tree view.""" + self.DefineItems([node]) + if not self.selected_command[0]: + return + + selected_command = self.selected_command[0] + self.showNotification.emit(message=selected_command.data["name"]) + + # Find the index of the selected command in history file + history_index = self._getIndexFromFile(selected_command) + + # Show command info in info panel + command_info = self.GetCommandInfo(history_index) self.infoPanel.showCommandInfo(command_info) def OnRightClick(self, node): - """Display popup menu""" - self._popupMenuLayer() + """Display popup menu.""" + self.DefineItems([node]) + if self.selected_command[0]: + self._popupMenuCommand() + else: + self._popupMenuEmpty() + + def OnDoubleClick(self, node): + """Double click on item/node. + + Launch module dialog if node is a command otherwise + expand/collapse node. + """ + self.DefineItems([node]) + if self.selected_command[0]: + self.Run(node) + else: + if self.IsNodeExpanded(node): + self.CollapseNode(node, recursive=False) + else: + self.ExpandNode(node, recursive=False) diff --git a/python/grass/grassdb/history.py b/python/grass/grassdb/history.py index 706edab2b7e..b1ddb52c19a 100644 --- a/python/grass/grassdb/history.py +++ b/python/grass/grassdb/history.py @@ -134,6 +134,25 @@ def read(history_path): return _read_from_plain_text(history_path) +def filter(json_data, command, timestamp): + """ + Filter JSON history file based on provided command and the time of command launch. + + :param json_data: List of dictionaries representing JSON entries + :param command: First filtering argument representing command as string + :param timestamp: Second filtering argument representing the time of command launch + :return: Index of entry matching the filter criteria. + """ + for index, entry in enumerate(json_data): + if entry["command_info"]: + if ( + entry["command"] == command + and entry["command_info"]["timestamp"] == timestamp + ): + return index + return None + + def _remove_entry_from_plain_text(history_path, index): """Remove entry from plain text history file.