From 736b0f4eb4fb52d6dce26a42c965a52d2b5c3c1b Mon Sep 17 00:00:00 2001 From: lindakladivova Date: Thu, 11 Apr 2024 13:05:33 +0200 Subject: [PATCH 1/7] first working version --- gui/wxpython/history/tree.py | 480 +++++++++++++++++++++++++------- python/grass/grassdb/history.py | 19 ++ 2 files changed, 392 insertions(+), 107 deletions(-) diff --git a/gui/wxpython/history/tree.py b/gui/wxpython/history/tree.py index cd67dd5fe90..f8d55d579d4 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, DictNode from gui_core.treeview import CTreeView from gui_core.wrap import Menu @@ -40,6 +41,49 @@ from grass.grassdb import history +class HistoryBrowserNode(DictNode): + """Node representing item in history browser.""" + + def __init__(self, data=None): + super().__init__(data=data) + + @property + def label(self): + if "time_period" in self.data: + return self.data["time_period"] + else: + return self.data["name"] + + 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' + """ + 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 HistoryBrowserTree(CTreeView): """Tree structure visualizing and managing history of executed commands. Uses virtual tree and model defined in core/treemodel.py. @@ -57,7 +101,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 +109,8 @@ def __init__( self.parent = parent self.infoPanel = infoPanel + self._resetSelectVariables() + self._initHistoryModel() self.showNotification = Signal("HistoryBrowserTree.showNotification") @@ -72,43 +118,62 @@ 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 _sortTimePeriods(self): + """Sort periods from newest to oldest based on the underlying timestamps.""" + if self._model.root.children: + self._model.root.children.sort( + key=lambda node: node.data["timestamp"], reverse=True ) - self._refreshTree() def _refreshTree(self): """Refresh tree models""" + self._sortTimePeriods() 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_time_period = [] + self.selected_command = [] + + def _getIndexFromFile(self, command_node): + """Get index of command node in the corresponding history log file.""" + if command_node.data["missing_info"]: + 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 _timestampToTimePeriod(self, timestamp): + """ + Convert timestamp to a time period label. + + :param timestamp: Timestamp string in ISO format. + :return: Corresponding time period label. + """ + current_date = datetime.date.today() + timestamp_date = datetime.datetime.fromisoformat(timestamp).date() + + if timestamp_date == current_date: + return _("Today") + elif timestamp_date == current_date - datetime.timedelta(days=1): + return _("Yesterday") + else: + return timestamp_date.strftime("%B %Y") def _confirmDialog(self, question, title): """Confirm dialog""" @@ -117,7 +182,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 +193,149 @@ 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 _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 time period node for entries with command info + timestamp = entry["command_info"].get("timestamp") + if timestamp: + time_period = self._model.SearchNodes( + parent=self._model.root, + time_period=self._timestampToTimePeriod(timestamp), + type="time_period", + ) + else: + # Find time period node prepared for entries without any command info + time_period = self._model.SearchNodes( + parent=self._model.root, + time_period=_("Missing info"), + type="time_period", + ) + + if time_period: + time_period = time_period[0] + else: + # Create time period node if not found + if not entry["command_info"]: + # Prepare it for entries without command info + random_history_date = datetime.datetime(1960, 1, 1).isoformat() + time_period = self._model.AppendNode( + parent=self._model.root, + data=dict( + type="time_period", + timestamp=random_history_date, + time_period=_("Missing info"), + ), + ) + else: + time_period = self._model.AppendNode( + parent=self._model.root, + data=dict( + type="time_period", + timestamp=entry["command_info"]["timestamp"], + time_period=self._timestampToTimePeriod( + entry["command_info"]["timestamp"] + ), + ), + ) + + # Determine status and create command node + if entry["command_info"]: + status = entry["command_info"].get("status", _("Unknown")) + else: + status = _("Unknown") + + self._model.AppendNode( + parent=time_period, + data=dict( + type="command", + name=entry["command"].strip(), + timestamp=timestamp if timestamp else _("Unknown"), + status=status, + missing_info=not bool(entry["command_info"]), + ), + ) + + # Sort time periods and refresh the tree view + self._sortTimePeriods() + self._refreshTree() + + 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_time_period.append(item.parent) + elif type == "time_period": + self.selected_command.append(None) + self.selected_time_period.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) 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 +343,177 @@ 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_nodes = self._model.SearchNodes( + parent=self._model.root, time_period=_("Today"), type="time_period" + ) + if not today_nodes: + today_node = self._model.AppendNode( + parent=self._model.root, + data=dict( + type="time_period", + timestamp=entry["command_info"]["timestamp"], + time_period=_("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"), + missing_info=False, + ), ) + + today_node = self._model.SearchNodes( + parent=self._model.root, time_period=_("Today"), type="time_period" + ) + + # Sort time periods and refresh the tree + self._sortTimePeriods() 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_node = self._model.SearchNodes( + parent=self._model.root, time_period=_("Today"), type="time_period" + )[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_time_period = self.selected_time_period[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_time_period = selected_command.parent + if selected_time_period and len(selected_time_period.children) == 0: + self._model.RemoveNode(selected_time_period) + + 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. From d7346cf3c72768e8d612096dcce3a1d7aeb639a2 Mon Sep 17 00:00:00 2001 From: lindakladivova Date: Thu, 18 Apr 2024 11:53:49 +0200 Subject: [PATCH 2/7] second version after refactoring --- gui/wxpython/history/tree.py | 187 +++++++++++++++++------------------ 1 file changed, 92 insertions(+), 95 deletions(-) diff --git a/gui/wxpython/history/tree.py b/gui/wxpython/history/tree.py index f8d55d579d4..682ddada0a9 100644 --- a/gui/wxpython/history/tree.py +++ b/gui/wxpython/history/tree.py @@ -49,8 +49,17 @@ def __init__(self, data=None): @property def label(self): - if "time_period" in self.data: - return self.data["time_period"] + if "number" in self.data.keys(): + if self.data["number"] == 0: + return _("Today") + elif self.data["number"] == 1: + return _("Yesterday") + elif self.data["number"] == 2: + return _("This week") + elif self.data["number"] == 3: + return _("Older than week") + else: + return _("Missing info") else: return self.data["name"] @@ -68,6 +77,7 @@ def match(self, method="exact", **kwargs): if not (key in self.data and self.data[key] == value): return False return True + # for filtering if ( "type" in kwargs @@ -130,10 +140,10 @@ def __init__( self.contextMenu.connect(self.OnRightClick) def _sortTimePeriods(self): - """Sort periods from newest to oldest based on the underlying timestamps.""" + """Sort periods from newest to oldest based on the node number.""" if self._model.root.children: self._model.root.children.sort( - key=lambda node: node.data["timestamp"], reverse=True + key=lambda node: node.data["number"], reverse=False ) def _refreshTree(self): @@ -147,34 +157,6 @@ def _resetSelectVariables(self): self.selected_time_period = [] self.selected_command = [] - def _getIndexFromFile(self, command_node): - """Get index of command node in the corresponding history log file.""" - if command_node.data["missing_info"]: - 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 _timestampToTimePeriod(self, timestamp): - """ - Convert timestamp to a time period label. - - :param timestamp: Timestamp string in ISO format. - :return: Corresponding time period label. - """ - current_date = datetime.date.today() - timestamp_date = datetime.datetime.fromisoformat(timestamp).date() - - if timestamp_date == current_date: - return _("Today") - elif timestamp_date == current_date - datetime.timedelta(days=1): - return _("Yesterday") - else: - return timestamp_date.strftime("%B %Y") - def _confirmDialog(self, question, title): """Confirm dialog""" dlg = wx.MessageDialog(self, question, title, wx.YES_NO) @@ -202,77 +184,97 @@ def _popupMenuEmpty(self): self.PopupMenu(menu) menu.Destroy() + 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 _initHistoryModel(self): """Fill tree history model based on the current history log.""" content_list = self.ReadFromHistory() + # Initialize time period nodes + for node_number in range(5): + self._model.AppendNode( + parent=self._model.root, + data=dict(type="time_period", number=node_number), + ) + + # Populate time period nodes for entry in content_list: - timestamp = None - if entry["command_info"]: - # Find time period node for entries with command info - timestamp = entry["command_info"].get("timestamp") - if timestamp: - time_period = self._model.SearchNodes( - parent=self._model.root, - time_period=self._timestampToTimePeriod(timestamp), - type="time_period", - ) - else: - # Find time period node prepared for entries without any command info - time_period = self._model.SearchNodes( - parent=self._model.root, - time_period=_("Missing info"), - type="time_period", - ) + # Determine node number + timestamp = ( + entry["command_info"]["timestamp"] if entry["command_info"] else None + ) + node_number = self._timestampToNodeNumber(timestamp) - if time_period: - time_period = time_period[0] - else: - # Create time period node if not found - if not entry["command_info"]: - # Prepare it for entries without command info - random_history_date = datetime.datetime(1960, 1, 1).isoformat() - time_period = self._model.AppendNode( - parent=self._model.root, - data=dict( - type="time_period", - timestamp=random_history_date, - time_period=_("Missing info"), - ), - ) - else: - time_period = self._model.AppendNode( - parent=self._model.root, - data=dict( - type="time_period", - timestamp=entry["command_info"]["timestamp"], - time_period=self._timestampToTimePeriod( - entry["command_info"]["timestamp"] - ), - ), - ) + # Find corresponding time period node + time_node = self._model.SearchNodes( + parent=self._model.root, number=node_number, type="time_period" + )[0] # Determine status and create command node - if entry["command_info"]: - status = entry["command_info"].get("status", _("Unknown")) - else: - status = _("Unknown") + status = ( + entry["command_info"].get("status") + if entry.get("command_info") + else None + ) + # Add command to time period node self._model.AppendNode( - parent=time_period, + parent=time_node, data=dict( type="command", name=entry["command"].strip(), - timestamp=timestamp if timestamp else _("Unknown"), + timestamp=timestamp if timestamp else None, status=status, - missing_info=not bool(entry["command_info"]), ), ) + # Remove empty time period nodes + for node_number in range(5): + time_node = self._model.SearchNodes( + parent=self._model.root, number=node_number, type="time_period" + )[0] + + if len(time_node.children) == 0: + self._model.RemoveNode(time_node) + # Sort time periods and refresh the tree view - self._sortTimePeriods() self._refreshTree() + def _timestampToNodeNumber(self, timestamp=None): + """ + Convert timestamp to a corresponding time period node number. + + :param timestamp: Time when the command was launched + :return: Corresponding time period node number: + Today = 0, Yesterday = 1, This week = 2, + Before week = 3, Missing info = 4 + """ + if not timestamp: + return 4 + + timestamp = datetime.datetime.fromisoformat(timestamp).date() + current = datetime.date.today() + yesterday = current - datetime.timedelta(days=1) + before_week = current - datetime.timedelta(days=7) + + if timestamp == current: + return 0 + elif timestamp == yesterday: + return 1 + elif timestamp > before_week: + return 2 + else: + return 3 + def ReadFromHistory(self): """Read content of command history log. It is a wrapper which considers GError. @@ -331,7 +333,9 @@ def Filter(self, text): compiled = re.compile(text) except re.error: return - self._model = self._orig_model.Filtered(method="filtering", name=compiled) + self._model = self._orig_model.Filtered( + method="filtering", name=compiled, type="command" + ) else: self._model = self._orig_model self.RefreshItems() @@ -350,15 +354,14 @@ def InsertCommand(self, entry): """ # Check if today time period node exists or create it today_nodes = self._model.SearchNodes( - parent=self._model.root, time_period=_("Today"), type="time_period" + parent=self._model.root, number=0, type="time_period" ) if not today_nodes: today_node = self._model.AppendNode( parent=self._model.root, data=dict( type="time_period", - timestamp=entry["command_info"]["timestamp"], - time_period=_("Today"), + number=0, ), ) else: @@ -372,16 +375,10 @@ def InsertCommand(self, entry): name=entry["command"].strip(), timestamp=entry["command_info"]["timestamp"], status=entry["command_info"].get("status", "In process"), - missing_info=False, ), ) - today_node = self._model.SearchNodes( - parent=self._model.root, time_period=_("Today"), type="time_period" - ) - - # Sort time periods and refresh the tree - self._sortTimePeriods() + # Refresh the tree self._refreshTree() # Select and expand the newly added command node @@ -398,7 +395,7 @@ def UpdateCommand(self, entry): """ # Get node of last command today_node = self._model.SearchNodes( - parent=self._model.root, time_period=_("Today"), type="time_period" + parent=self._model.root, number=0, type="time_period" )[0] command_nodes = self._model.SearchNodes(parent=today_node, type="command") last_node = command_nodes[-1] From 60b53550331237106b020c60f6580bb42931faa2 Mon Sep 17 00:00:00 2001 From: lindakladivova Date: Fri, 19 Apr 2024 13:13:06 +0200 Subject: [PATCH 3/7] new class DictFilterNode created due to duplication issue --- gui/wxpython/core/treemodel.py | 44 ++++++++++++++++++++++++++++++++ gui/wxpython/datacatalog/tree.py | 34 ++---------------------- gui/wxpython/history/tree.py | 40 ++++------------------------- 3 files changed, 51 insertions(+), 67 deletions(-) diff --git a/gui/wxpython/core/treemodel.py b/gui/wxpython/core/treemodel.py index 1c1fc2deee9..cc521189d02 100644 --- a/gui/wxpython/core/treemodel.py +++ b/gui/wxpython/core/treemodel.py @@ -239,6 +239,50 @@ 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 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' + """ + 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 02d68f938a1..77f9e19d783 100644 --- a/gui/wxpython/datacatalog/tree.py +++ b/gui/wxpython/datacatalog/tree.py @@ -37,7 +37,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 @@ -170,7 +170,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): @@ -196,36 +196,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 682ddada0a9..93cbabd4919 100644 --- a/gui/wxpython/history/tree.py +++ b/gui/wxpython/history/tree.py @@ -32,7 +32,7 @@ split, ) from gui_core.forms import GUI -from core.treemodel import TreeModel, DictNode +from core.treemodel import TreeModel, DictFilterNode from gui_core.treeview import CTreeView from gui_core.wrap import Menu @@ -41,7 +41,7 @@ from grass.grassdb import history -class HistoryBrowserNode(DictNode): +class HistoryBrowserNode(DictFilterNode): """Node representing item in history browser.""" def __init__(self, data=None): @@ -63,36 +63,6 @@ def label(self): else: return self.data["name"] - 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' - """ - 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 HistoryBrowserTree(CTreeView): """Tree structure visualizing and managing history of executed commands. @@ -256,7 +226,7 @@ def _timestampToNodeNumber(self, timestamp=None): :param timestamp: Time when the command was launched :return: Corresponding time period node number: Today = 0, Yesterday = 1, This week = 2, - Before week = 3, Missing info = 4 + Older than week = 3, Missing info = 4 """ if not timestamp: return 4 @@ -264,13 +234,13 @@ def _timestampToNodeNumber(self, timestamp=None): timestamp = datetime.datetime.fromisoformat(timestamp).date() current = datetime.date.today() yesterday = current - datetime.timedelta(days=1) - before_week = current - datetime.timedelta(days=7) + week_ago = current - datetime.timedelta(days=7) if timestamp == current: return 0 elif timestamp == yesterday: return 1 - elif timestamp > before_week: + elif timestamp > week_ago: return 2 else: return 3 From 3b4f0243fb8093e4e55a980f5e7219c9966fe7ac Mon Sep 17 00:00:00 2001 From: lindakladivova Date: Mon, 22 Apr 2024 09:50:58 +0200 Subject: [PATCH 4/7] docstring improved --- gui/wxpython/core/treemodel.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/gui/wxpython/core/treemodel.py b/gui/wxpython/core/treemodel.py index cc521189d02..64b3a4dc6df 100644 --- a/gui/wxpython/core/treemodel.py +++ b/gui/wxpython/core/treemodel.py @@ -248,8 +248,10 @@ def __init__(self, data=None): 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 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 From ba587728071d3a32dee07badd8f0e9dba2b34bfa Mon Sep 17 00:00:00 2001 From: lindakladivova Date: Thu, 25 Apr 2024 12:38:22 +0200 Subject: [PATCH 5/7] grouping of time period nodes changed to grouping by days --- gui/wxpython/history/tree.py | 209 ++++++++++++++++++++--------------- 1 file changed, 117 insertions(+), 92 deletions(-) diff --git a/gui/wxpython/history/tree.py b/gui/wxpython/history/tree.py index 93cbabd4919..32559ef3e23 100644 --- a/gui/wxpython/history/tree.py +++ b/gui/wxpython/history/tree.py @@ -41,6 +41,10 @@ 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.""" @@ -49,20 +53,37 @@ def __init__(self, data=None): @property def label(self): - if "number" in self.data.keys(): - if self.data["number"] == 0: - return _("Today") - elif self.data["number"] == 1: - return _("Yesterday") - elif self.data["number"] == 2: - return _("This week") - elif self.data["number"] == 3: - return _("Older than week") - else: - return _("Missing info") + 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 ( + current_date - datetime.timedelta(days=current_date.weekday()) + <= day + <= current_date + ): + return "{:%B %-d} (this week)".format(day) + elif day.year == current_date.year and day.month == current_date.month: + return "{:%B %-d} (this month)".format(day) + else: + return "{:%B %-d, %Y}".format(day) + class HistoryBrowserTree(CTreeView): """Tree structure visualizing and managing history of executed commands. @@ -109,22 +130,22 @@ def __init__( self.itemActivated.connect(self.OnDoubleClick) self.contextMenu.connect(self.OnRightClick) - def _sortTimePeriods(self): - """Sort periods from newest to oldest based on the node number.""" + 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["number"], reverse=False + key=lambda node: node.data["day"], reverse=True ) def _refreshTree(self): """Refresh tree models""" - self._sortTimePeriods() + self._sortDays() self.SetModel(copy.deepcopy(self._model)) self._orig_model = self._model def _resetSelectVariables(self): """Reset variables related to item selection.""" - self.selected_time_period = [] + self.selected_day = [] self.selected_command = [] def _confirmDialog(self, question, title): @@ -154,51 +175,77 @@ def _popupMenuEmpty(self): self.PopupMenu(menu) menu.Destroy() - 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 _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() - # Initialize time period nodes - for node_number in range(5): - self._model.AppendNode( - parent=self._model.root, - data=dict(type="time_period", number=node_number), - ) - - # Populate time period nodes for entry in content_list: - # Determine node number - timestamp = ( - entry["command_info"]["timestamp"] if entry["command_info"] else None - ) - node_number = self._timestampToNodeNumber(timestamp) - - # Find corresponding time period node - time_node = self._model.SearchNodes( - parent=self._model.root, number=node_number, type="time_period" - )[0] + 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") - else None + and entry["command_info"].get("status") is not None + else "unknown" ) # Add command to time period node self._model.AppendNode( - parent=time_node, + parent=day, data=dict( type="command", name=entry["command"].strip(), @@ -207,43 +254,19 @@ def _initHistoryModel(self): ), ) - # Remove empty time period nodes - for node_number in range(5): - time_node = self._model.SearchNodes( - parent=self._model.root, number=node_number, type="time_period" - )[0] - - if len(time_node.children) == 0: - self._model.RemoveNode(time_node) - - # Sort time periods and refresh the tree view + # Refresh the tree view self._refreshTree() - def _timestampToNodeNumber(self, timestamp=None): - """ - Convert timestamp to a corresponding time period node number. - - :param timestamp: Time when the command was launched - :return: Corresponding time period node number: - Today = 0, Yesterday = 1, This week = 2, - Older than week = 3, Missing info = 4 - """ - if not timestamp: - return 4 - - timestamp = datetime.datetime.fromisoformat(timestamp).date() - current = datetime.date.today() - yesterday = current - datetime.timedelta(days=1) - week_ago = current - datetime.timedelta(days=7) - - if timestamp == current: - return 0 - elif timestamp == yesterday: - return 1 - elif timestamp > week_ago: - return 2 + 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 3 + 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. @@ -289,10 +312,10 @@ def DefineItems(self, selected): type = item.data["type"] if type == "command": self.selected_command.append(item) - self.selected_time_period.append(item.parent) - elif type == "time_period": + self.selected_day.append(item.parent) + elif type == "day": self.selected_command.append(None) - self.selected_time_period.append(item) + self.selected_day.append(item) def Filter(self, text): """Filter history @@ -323,15 +346,16 @@ def InsertCommand(self, entry): :param entry dict: entry with 'command' and 'command_info' keys """ # 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, number=0, type="time_period" + parent=self._model.root, day=today, type="day" ) if not today_nodes: today_node = self._model.AppendNode( parent=self._model.root, data=dict( - type="time_period", - number=0, + type="day", + day=today, ), ) else: @@ -344,7 +368,7 @@ def InsertCommand(self, entry): type="command", name=entry["command"].strip(), timestamp=entry["command_info"]["timestamp"], - status=entry["command_info"].get("status", "In process"), + status=entry["command_info"].get("status", "in process"), ), ) @@ -364,8 +388,9 @@ def UpdateCommand(self, entry): :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, number=0, type="time_period" + parent=self._model.root, day=today, type="day" )[0] command_nodes = self._model.SearchNodes(parent=today_node, type="command") last_node = command_nodes[-1] @@ -419,7 +444,7 @@ def OnRemoveCmd(self, event): return selected_command = self.selected_command[0] - selected_time_period = self.selected_time_period[0] + selected_day = self.selected_day[0] command = selected_command.data["name"] # Confirm deletion with user @@ -439,9 +464,9 @@ def OnRemoveCmd(self, event): self._model.RemoveNode(selected_command) # Check if the time period node should also be removed - selected_time_period = selected_command.parent - if selected_time_period and len(selected_time_period.children) == 0: - self._model.RemoveNode(selected_time_period) + 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)) From f3b9e932fcdb90b6363b673aca3f36244e79bb65 Mon Sep 17 00:00:00 2001 From: lindakladivova Date: Thu, 25 Apr 2024 12:52:22 +0200 Subject: [PATCH 6/7] (This week) fix --- gui/wxpython/history/tree.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/gui/wxpython/history/tree.py b/gui/wxpython/history/tree.py index 32559ef3e23..deb140a83e0 100644 --- a/gui/wxpython/history/tree.py +++ b/gui/wxpython/history/tree.py @@ -41,7 +41,7 @@ from grass.grassdb import history -# global variable for purposes of sorting No time info node +# global variable for purposes of sorting "No time info" node OLD_DATE = datetime.datetime(1950, 1, 1).date() @@ -73,11 +73,7 @@ def dayToLabel(self, day): return "{:%B %-d} (today)".format(day) elif day == current_date - datetime.timedelta(days=1): return "{:%B %-d} (yesterday)".format(day) - elif ( - current_date - datetime.timedelta(days=current_date.weekday()) - <= day - <= current_date - ): + elif day >= current_date - datetime.timedelta(days=7): return "{:%B %-d} (this week)".format(day) elif day.year == current_date.year and day.month == current_date.month: return "{:%B %-d} (this month)".format(day) From dedd1088db3d06784128cd0c1685692c3a1fd8b7 Mon Sep 17 00:00:00 2001 From: lindakladivova Date: Thu, 2 May 2024 09:27:51 +0200 Subject: [PATCH 7/7] small edits of time period labels --- gui/wxpython/history/tree.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/gui/wxpython/history/tree.py b/gui/wxpython/history/tree.py index deb140a83e0..caaeb8bda86 100644 --- a/gui/wxpython/history/tree.py +++ b/gui/wxpython/history/tree.py @@ -73,10 +73,10 @@ def dayToLabel(self, day): 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=7): + elif day >= (current_date - datetime.timedelta(days=current_date.weekday())): return "{:%B %-d} (this week)".format(day) - elif day.year == current_date.year and day.month == current_date.month: - return "{:%B %-d} (this month)".format(day) + elif day.year == current_date.year: + return "{:%B %-d}".format(day) else: return "{:%B %-d, %Y}".format(day)