1""" 2 @file 3 @brief This file contains the classes needed for tracking updates and distributing changes 4 @author Noah Figg <eggmunkee@hotmail.com> 5 @author Jonathan Thomas <jonathan@openshot.org> 6 @author Olivier Girard <eolinwen@gmail.com> 7 8 @section LICENSE 9 10 Copyright (c) 2008-2018 OpenShot Studios, LLC 11 (http://www.openshotstudios.com). This file is part of 12 OpenShot Video Editor (http://www.openshot.org), an open-source project 13 dedicated to delivering high quality video editing and animation solutions 14 to the world. 15 16 OpenShot Video Editor is free software: you can redistribute it and/or modify 17 it under the terms of the GNU General Public License as published by 18 the Free Software Foundation, either version 3 of the License, or 19 (at your option) any later version. 20 21 OpenShot Video Editor is distributed in the hope that it will be useful, 22 but WITHOUT ANY WARRANTY; without even the implied warranty of 23 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 24 GNU General Public License for more details. 25 26 You should have received a copy of the GNU General Public License 27 along with OpenShot Library. If not, see <http://www.gnu.org/licenses/>. 28 """ 29 30from classes.logger import log 31import copy 32import json 33 34 35class UpdateWatcher: 36 """ Interface for classes that listen for 'undo' and 'redo' events. """ 37 38 def updateStatusChanged(self, undo_status, redo_status): 39 """ Easily be notified each time there are 'undo' or 'redo' actions 40 available in the UpdateManager. """ 41 raise NotImplementedError("updateStatus() not implemented in UpdateWatcher implementer.") 42 43 44class UpdateInterface: 45 """ Interface for classes that listen for changes (insert, update, and delete). """ 46 47 def changed(self, action): 48 """ This method is invoked each time the UpdateManager is changed. 49 The action contains all the details of what changed, 50 including the type of change (insert, update, or delete). """ 51 raise NotImplementedError("changed() not implemented in UpdateInterface implementer.") 52 53 54class UpdateAction: 55 """A data structure representing a single update manager action, 56 including any necessary data to reverse the action.""" 57 58 def __init__(self, type=None, key=[], values=None, partial_update=False): 59 self.type = type # insert, update, or delete 60 self.key = key # list which contains the path to the item, for example: ["clips",{"id":"123"}] 61 self.values = values 62 self.old_values = None 63 self.partial_update = partial_update 64 65 def set_old_values(self, old_vals): 66 self.old_values = old_vals 67 68 def json(self, is_array=False, only_value=False): 69 """ Get the JSON string representing this UpdateAction """ 70 71 # Build the dictionary to be serialized 72 if only_value: 73 data_dict = copy.deepcopy(self.values) 74 else: 75 data_dict = {"type": self.type, 76 "key": self.key, 77 "value": copy.deepcopy(self.values), 78 "partial": self.partial_update, 79 "old_values": copy.deepcopy(self.old_values)} 80 81 # Always remove 'history' key (if found). This prevents nested "history" 82 # attributes when a project dict is loaded. 83 try: 84 if isinstance(data_dict.get("value"), dict) and "history" in data_dict.get("value"): 85 data_dict.get("value").pop("history", None) 86 if isinstance(data_dict.get("old_values"), dict) and "history" in data_dict.get("old_values"): 87 data_dict.get("old_values").pop("history", None) 88 except Exception as ex: 89 log.warning('Failed to clear history attribute from undo/redo data. {}'.format(ex)) 90 91 if not is_array: 92 # Use a JSON Object as the root object 93 update_action_dict = data_dict 94 else: 95 # Use a JSON Array as the root object 96 update_action_dict = [data_dict] 97 98 # Serialize as JSON 99 return json.dumps(update_action_dict) 100 101 def load_json(self, value): 102 """ Load this UpdateAction from a JSON string """ 103 104 # Load JSON string 105 update_action_dict = json.loads(value, strict=False) 106 107 # Set the Update Action properties 108 self.type = update_action_dict.get("type") 109 self.key = update_action_dict.get("key") 110 self.values = update_action_dict.get("value") 111 self.old_values = update_action_dict.get("old_values") 112 self.partial_update = update_action_dict.get("partial") 113 114 # Always remove 'history' key (if found). This prevents nested "history" 115 # attributes when a project dict is loaded. 116 try: 117 if isinstance(self.values, dict) and "history" in self.values: 118 self.values.pop("history", None) 119 if isinstance(self.old_values, dict) and "history" in self.old_values: 120 self.old_values.pop("history", None) 121 except Exception as ex: 122 log.warning('Failed to clear history attribute from undo/redo data. {}'.format(ex)) 123 124 125class UpdateManager: 126 """ This class is used to track and distribute changes to listeners. 127 Typically, only 1 instance of this class is needed, and many different 128 listeners are connected with the add_listener() method. """ 129 130 def __init__(self): 131 self.statusWatchers = [] # List of watchers 132 self.updateListeners = [] # List of listeners 133 self.actionHistory = [] # List of actions performed to current state 134 self.redoHistory = [] # List of actions undone 135 self.currentStatus = [None, None] # Status of Undo and Redo buttons (true/false for should be enabled) 136 self.ignore_history = False # Ignore saving actions to history, to prevent a huge undo/redo list 137 self.last_action = None # The last action processed 138 self.pending_action = None # Last action not added to actionHistory list 139 140 def load_history(self, project): 141 """Load history from project""" 142 self.reset() 143 144 # Get history from project data 145 history = project.get("history") 146 147 # Loop through each, and load serialized data into updateAction objects 148 # Ignore any load actions or history update actions 149 for actionDict in history.get("redo", []): 150 action = UpdateAction() 151 action.load_json(json.dumps(actionDict)) 152 if action.type != "load" and action.key[0] != "history": 153 self.redoHistory.append(action) 154 else: 155 log.info("Loading redo history, skipped key: %s" % str(action.key)) 156 for actionDict in history.get("undo", []): 157 action = UpdateAction() 158 action.load_json(json.dumps(actionDict)) 159 if action.type != "load" and action.key[0] != "history": 160 self.actionHistory.append(action) 161 else: 162 log.info("Loading undo history, skipped key: %s" % str(action.key)) 163 164 # Notify watchers of new status 165 self.update_watchers() 166 167 def save_history(self, project, history_length): 168 """Save history to project""" 169 redo_list = [] 170 undo_list = [] 171 172 # Loop through each updateAction object and serialize 173 # Ignore any load actions or history update actions 174 history_length_int = int(history_length) 175 if history_length_int == 0: 176 self.update_untracked(["history"], {"redo": [], "undo": []}) 177 return 178 for action in self.redoHistory[-history_length_int:]: 179 if action.type != "load" and action.key[0] != "history": 180 actionDict = json.loads(action.json(), strict=False) 181 redo_list.append(actionDict) 182 else: 183 log.info("Saving redo history, skipped key: %s" % str(action.key)) 184 for action in self.actionHistory[-history_length_int:]: 185 if action.type != "load" and action.key[0] != "history": 186 actionDict = json.loads(action.json(), strict=False) 187 undo_list.append(actionDict) 188 else: 189 log.info("Saving undo history, skipped key: %s" % str(action.key)) 190 191 # Set history data in project 192 self.update_untracked(["history"], {"redo": redo_list, "undo": undo_list}) 193 194 def reset(self): 195 """ Reset the UpdateManager, and clear all UpdateActions and History. 196 This does not clear listeners and watchers. """ 197 self.actionHistory.clear() 198 self.redoHistory.clear() 199 self.pending_action = None 200 self.last_action = None 201 202 # Notify watchers of new history state 203 self.update_watchers() 204 205 def add_listener(self, listener, index=-1): 206 """ Add a new listener (which will invoke the changed(action) method 207 each time an UpdateAction is available). """ 208 209 if listener not in self.updateListeners: 210 if index <= -1: 211 # Add listener to end of list 212 self.updateListeners.append(listener) 213 else: 214 # Insert listener at index 215 self.updateListeners.insert(index, listener) 216 else: 217 log.warning("Cannot add existing listener: {}".format(str(listener))) 218 219 def add_watcher(self, watcher): 220 """ Add a new watcher (which will invoke the updateStatusChanged() method 221 each time a 'redo' or 'undo' action is available). """ 222 223 if watcher not in self.statusWatchers: 224 self.statusWatchers.append(watcher) 225 else: 226 log.warning("Cannot add existing watcher: {}".format(str(watcher))) 227 228 def update_watchers(self): 229 """ Notify all watchers if any 'undo' or 'redo' actions are available. """ 230 231 new_status = (len(self.actionHistory) >= 1, len(self.redoHistory) >= 1) 232 if self.currentStatus[0] != new_status[0] or self.currentStatus[1] != new_status[1]: 233 for watcher in self.statusWatchers: 234 watcher.updateStatusChanged(*new_status) 235 236 # This can only be called on actions already run, 237 # as the old_values member is only populated during the 238 # add/update/remove task on the project data store. 239 # the old_values member is needed to reverse the changes 240 # caused by actions. 241 def get_reverse_action(self, action): 242 """ Convert an UpdateAction into the opposite type (i.e. 'insert' becomes an 'delete') """ 243 reverse = UpdateAction(action.type, action.key, action.values, action.partial_update) 244 # On adds, setup remove 245 if action.type == "insert": 246 reverse.type = "delete" 247 248 # replace last part of key with ID (so the delete knows which item to delete) 249 id = action.values["id"] 250 action.key.append({"id": id}) 251 252 # On removes, setup add with old value 253 elif action.type == "delete": 254 reverse.type = "insert" 255 # Remove last item from key (usually the id of the inserted item) 256 if reverse.type == "insert" and isinstance(reverse.key[-1], dict) and "id" in reverse.key[-1]: 257 reverse.key = reverse.key[:-1] 258 259 # On updates, just swap the old and new values data 260 # Swap old and new values 261 reverse.old_values = action.values 262 reverse.values = action.old_values 263 264 return reverse 265 266 def undo(self): 267 """ Undo the last UpdateAction (and notify all listeners and watchers) """ 268 269 if len(self.actionHistory) > 0: 270 # Get last action from history (remove) 271 last_action = copy.deepcopy(self.actionHistory.pop()) 272 273 self.redoHistory.append(last_action) 274 self.pending_action = None 275 # Get reverse of last action and perform it 276 reverse_action = self.get_reverse_action(last_action) 277 self.dispatch_action(reverse_action) 278 279 def redo(self): 280 """ Redo the last UpdateAction (and notify all listeners and watchers) """ 281 282 if len(self.redoHistory) > 0: 283 # Get last undone action off redo history (remove) 284 next_action = copy.deepcopy(self.redoHistory.pop()) 285 286 # Remove ID from insert (if found) 287 if next_action.type == "insert" and isinstance(next_action.key[-1], dict) and "id" in next_action.key[-1]: 288 next_action.key = next_action.key[:-1] 289 290 self.actionHistory.append(next_action) 291 self.pending_action = None 292 # Perform next redo action 293 self.dispatch_action(next_action) 294 295 # Carry out an action on all listeners 296 def dispatch_action(self, action): 297 """ Distribute changes to all listeners (by calling their changed() method) """ 298 299 try: 300 # Loop through all listeners 301 for listener in self.updateListeners: 302 # Invoke change method on listener 303 listener.changed(action) 304 305 except Exception as ex: 306 log.error("Couldn't apply '{}' to update listener: {}\n{}".format(action.type, listener, ex)) 307 self.update_watchers() 308 309 # Perform load action (loading all project data), clearing history for taking a new path 310 def load(self, values): 311 """ Load all project data via an UpdateAction into the UpdateManager 312 (this action will then be distributed to all listeners) """ 313 314 self.last_action = UpdateAction('load', '', values) 315 self.redoHistory.clear() 316 self.actionHistory.clear() 317 self.pending_action = None 318 self.dispatch_action(self.last_action) 319 320 # Perform new actions, clearing redo history for taking a new path 321 def insert(self, key, values): 322 """ Insert a new UpdateAction into the UpdateManager 323 (this action will then be distributed to all listeners) """ 324 325 self.last_action = UpdateAction('insert', key, values) 326 if self.ignore_history: 327 self.pending_action = self.last_action 328 else: 329 self.redoHistory.clear() 330 self.pending_action = None 331 self.actionHistory.append(self.last_action) 332 self.dispatch_action(self.last_action) 333 334 def update(self, key, values, partial_update=False): 335 """ Update the UpdateManager with an UpdateAction 336 (this action will then be distributed to all listeners) """ 337 338 self.last_action = UpdateAction('update', key, values, partial_update) 339 if self.ignore_history: 340 self.pending_action = self.last_action 341 else: 342 if self.last_action.key and self.last_action.key[0] != "history": 343 # Clear redo history for any update except a "history" update 344 self.redoHistory.clear() 345 self.pending_action = None 346 self.actionHistory.append(self.last_action) 347 self.dispatch_action(self.last_action) 348 349 def update_untracked(self, key, values, partial_update=False): 350 """ Update the UpdateManager with an UpdateAction, without creating 351 a new entry in the history table 352 (this action will then be distributed to all listeners) """ 353 previous_ignore = self.ignore_history 354 previous_pending = self.pending_action 355 self.ignore_history = True 356 self.update(key, values, partial_update) 357 self.ignore_history = previous_ignore 358 self.pending_action = previous_pending 359 360 def delete(self, key): 361 """ Delete an item from the UpdateManager with an UpdateAction 362 (this action will then be distributed to all listeners) """ 363 364 self.last_action = UpdateAction('delete', key) 365 if self.ignore_history: 366 self.pending_action = self.last_action 367 else: 368 self.redoHistory.clear() 369 self.pending_action = None 370 self.actionHistory.append(self.last_action) 371 self.dispatch_action(self.last_action) 372 373 def apply_last_action_to_history(self, previous_value): 374 """ Apply the last action to the history """ 375 if self.pending_action: 376 self.pending_action.set_old_values(previous_value) 377 self.actionHistory.append(self.pending_action) 378 self.last_action = self.pending_action 379 self.pending_action = None 380 381 # Notify watchers of new history state 382 self.update_watchers() 383