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