1# -*- coding: utf-8 -*-
2
3# Copyright (c) 2017 - 2021 Detlev Offenbach <detlev@die-offenbachs.de>
4#
5
6"""
7Module implementing the session manager.
8"""
9
10import os
11import json
12import functools
13import contextlib
14
15from PyQt5.QtCore import (
16    pyqtSlot, pyqtSignal, Qt, QObject, QTimer, QDir, QFile, QFileInfo,
17    QFileSystemWatcher, QByteArray, QDateTime
18)
19from PyQt5.QtWidgets import (
20    QActionGroup, QApplication, QInputDialog, QLineEdit, QDialog,
21    QDialogButtonBox, QLabel, QComboBox, QVBoxLayout
22)
23
24from E5Gui import E5MessageBox
25from E5Gui.E5OverrideCursor import E5OverrideCursor
26
27import Utilities
28import Preferences
29
30
31class SessionMetaData:
32    """
33    Class implementing a data structure to store meta data for a session.
34    """
35    def __init__(self):
36        """
37        Constructor
38        """
39        self.name = ""
40        self.filePath = ""
41        self.isActive = False
42        self.isDefault = False
43        self.isBackup = False
44
45
46class SessionManager(QObject):
47    """
48    Class implementing the session manager.
49
50    @signal sessionsMetaDataChanged() emitted to indicate a change of the
51        list of session meta data
52    """
53    sessionsMetaDataChanged = pyqtSignal()
54
55    SwitchSession = 1
56    CloneSession = 2
57    ReplaceSession = SwitchSession | 4
58    RestoreSession = 8
59
60    def __init__(self, parent=None):
61        """
62        Constructor
63
64        @param parent reference to the parent object
65        @type QObject
66        """
67        super().__init__(parent)
68
69        sessionsDirName = self.getSessionsDirectory()
70        sessionsDir = QDir(sessionsDirName)
71        if not sessionsDir.exists():
72            sessionsDir.mkpath(sessionsDirName)
73
74        self.__sessionMetaData = []
75        # list containing meta data about saved sessions
76
77        self.__sessionDefault = os.path.join(sessionsDirName, "session.json")
78        self.__sessionBackup1 = os.path.join(sessionsDirName,
79                                             "session.json.old")
80        self.__sessionBackup2 = os.path.join(sessionsDirName,
81                                             "session.json.old1")
82
83        self.__lastActiveSession = Preferences.getWebBrowser(
84            "SessionLastActivePath")
85        if not QFile.exists(self.__lastActiveSession):
86            self.__lastActiveSession = self.__sessionDefault
87
88        self.__sessionsDirectoryWatcher = QFileSystemWatcher(
89            [self.getSessionsDirectory()], self)
90        self.__sessionsDirectoryWatcher.directoryChanged.connect(
91            self.__sessionDirectoryChanged)
92
93        self.__backupSavedSession()
94
95        self.__autoSaveTimer = None
96        self.__shutdown = False
97
98    def activateTimer(self):
99        """
100        Public method to activate the session save timer.
101        """
102        if self.__autoSaveTimer is None:
103            self.__autoSaveTimer = QTimer()
104            self.__autoSaveTimer.setSingleShot(True)
105            self.__autoSaveTimer.timeout.connect(self.__autoSaveSession)
106            self.__initSessionSaveTimer()
107
108    def preferencesChanged(self):
109        """
110        Public slot to react upon changes of the settings.
111        """
112        self.__initSessionSaveTimer()
113
114    def getSessionsDirectory(self):
115        """
116        Public method to get the directory sessions are stored in.
117
118        @return name of the sessions directory
119        @rtype str
120        """
121        return os.path.join(Utilities.getConfigDir(),
122                            "web_browser", "sessions")
123
124    def defaultSessionFile(self):
125        """
126        Public method to get the name of the default session file.
127
128        @return name of the default session file
129        @rtype str
130        """
131        return self.__sessionDefault
132
133    def lastActiveSessionFile(self):
134        """
135        Public method to get the name of the last active session file.
136
137        @return name of the last active session file
138        @rtype str
139        """
140        return self.__lastActiveSession
141
142    def shutdown(self):
143        """
144        Public method to perform any shutdown actions.
145        """
146        self.__autoSaveTimer.stop()
147        if not self.__shutdown:
148            self.__autoSaveSession(startTimer=False)
149        self.__shutdown = True
150
151    def autoSaveSession(self):
152        """
153        Public method to save the current session state.
154        """
155        self.__autoSaveSession(startTimer=False)
156
157    def __initSessionSaveTimer(self):
158        """
159        Private slot to initialize the auto save timer.
160        """
161        self.__autoSaveInterval = Preferences.getWebBrowser(
162            "SessionAutoSaveInterval") * 1000
163
164        if Preferences.getWebBrowser("SessionAutoSave"):
165            if not self.__autoSaveTimer.isActive():
166                self.__autoSaveTimer.start(self.__autoSaveInterval)
167        else:
168            self.__autoSaveTimer.stop()
169
170    @pyqtSlot()
171    def __autoSaveSession(self, startTimer=True):
172        """
173        Private slot to save the current session state.
174
175        @param startTimer flag indicating to restart the timer
176        @type bool
177        """
178        from WebBrowser.WebBrowserWindow import WebBrowserWindow
179
180        if not WebBrowserWindow.isPrivate():
181            Preferences.setWebBrowser("SessionLastActivePath",
182                                      self.__lastActiveSession)
183            self.writeCurrentSession(self.__lastActiveSession)
184
185        if startTimer:
186            self.__autoSaveTimer.start(self.__autoSaveInterval)
187
188    def writeCurrentSession(self, sessionFileName):
189        """
190        Public method to write the current session to the given file name.
191
192        @param sessionFileName file name of the session
193        @type str
194        """
195        from WebBrowser.WebBrowserWindow import WebBrowserWindow
196
197        sessionData = {"Windows": []}
198
199        activeWindow = WebBrowserWindow.getWindow()
200        for window in WebBrowserWindow.mainWindows():
201            data = window.tabWidget().getSessionData()
202
203            # add window geometry
204            geometry = window.saveGeometry()
205            data["WindowGeometry"] = bytes(geometry.toBase64()).decode("ascii")
206
207            sessionData["Windows"].append(data)
208
209            if window is activeWindow:
210                sessionData["CurrentWindowIndex"] = (
211                    len(sessionData["Windows"]) - 1
212                )
213
214        if sessionData["Windows"]:
215            with open(sessionFileName, "w") as sessionFile:
216                json.dump(sessionData, sessionFile, indent=2)
217
218    @classmethod
219    def readSessionFromFile(cls, sessionFileName):
220        """
221        Class method to read the session data from a file.
222
223        @param sessionFileName file name of the session file
224        @type str
225        @return dictionary containing the session data
226        @rtype dict
227        """
228        try:
229            with open(sessionFileName, "r") as sessionFile:
230                sessionData = json.load(sessionFile)
231            if not cls.isValidSession(sessionData):
232                sessionData = {}
233        except OSError:
234            sessionData = {}
235
236        return sessionData
237
238    @classmethod
239    def isValidSession(cls, session):
240        """
241        Class method to check the validity of a session.
242
243        @param session dictionary containing the session data
244        @type dict
245        @return flag indicating validity
246        @rtype bool
247        """
248        if not session:
249            return False
250
251        if "Windows" not in session:
252            return False
253
254        if not session["Windows"]:
255            return False
256
257        return True
258
259    def __backupSavedSession(self):
260        """
261        Private method to backup the most recently saved session.
262        """
263        if QFile.exists(self.__lastActiveSession):
264
265            if QFile.exists(self.__sessionBackup1):
266                QFile.remove(self.__sessionBackup2)
267                QFile.copy(self.__sessionBackup1, self.__sessionBackup2)
268
269            QFile.remove(self.__sessionBackup1)
270            QFile.copy(self.__lastActiveSession, self.__sessionBackup1)
271
272    def sessionMetaData(self, includeBackups=False):
273        """
274        Public method to get the sessions meta data.
275
276        @param includeBackups flag indicating to include backup sessions
277        @type bool
278        @return list of session meta data
279        @rtype list of SessionMetaData
280        """
281        self.__fillMetaDataList()
282
283        metaDataList = self.__sessionMetaData[:]
284
285        if includeBackups and QFile.exists(self.__sessionBackup1):
286            data = SessionMetaData()
287            data.name = self.tr("Backup 1")
288            data.filePath = self.__sessionBackup1
289            data.isBackup = True
290            metaDataList.append(data)
291
292        if includeBackups and QFile.exists(self.__sessionBackup2):
293            data = SessionMetaData()
294            data.name = self.tr("Backup 2")
295            data.filePath = self.__sessionBackup2
296            data.isBackup = True
297            metaDataList.append(data)
298
299        return metaDataList
300
301    def __fillMetaDataList(self):
302        """
303        Private method to fill the sessions meta data list.
304
305        The sessions meta data list is only populated, if the variable holding
306        it is empty (i.e. it is populated on demand).
307        """
308        if self.__sessionMetaData:
309            return
310
311        sessionFilesInfoList = QDir(self.getSessionsDirectory()).entryInfoList(
312            ["*.json"], QDir.Filter.Files, QDir.SortFlag.Time)
313
314        for sessionFileInfo in sessionFilesInfoList:
315            sessionData = self.readSessionFromFile(
316                sessionFileInfo.absoluteFilePath())
317            if not sessionData or not sessionData["Windows"]:
318                continue
319
320            data = SessionMetaData()
321            data.name = sessionFileInfo.baseName()
322            data.filePath = sessionFileInfo.canonicalFilePath()
323
324            if sessionFileInfo == QFileInfo(self.defaultSessionFile()):
325                data.name = self.tr("Default Session")
326                data.isDefault = True
327
328            if self.__isActive(sessionFileInfo):
329                data.isActive = True
330
331            if data.isDefault:
332                # default session is always first
333                self.__sessionMetaData.insert(0, data)
334            else:
335                self.__sessionMetaData.append(data)
336
337    def __isActive(self, filePath):
338        """
339        Private method to check, if a given file is the active one.
340
341        @param filePath path of the session file to be checked
342        @type str or QFileInfo
343        @return flag indicating the active file
344        @rtype bool
345        """
346        return QFileInfo(filePath) == QFileInfo(self.__lastActiveSession)
347
348    @pyqtSlot()
349    def __sessionDirectoryChanged(self):
350        """
351        Private slot handling changes of the sessions directory.
352        """
353        self.__sessionMetaData = []
354
355        self.sessionsMetaDataChanged.emit()
356
357    @pyqtSlot()
358    def aboutToShowSessionsMenu(self, menu):
359        """
360        Public slot to populate the sessions selection menu.
361
362        @param menu reference to the menu about to be shown
363        @type QMenu
364        """
365        menu.clear()
366
367        actionGroup = QActionGroup(menu)
368        sessions = self.sessionMetaData(includeBackups=False)
369        for session in sessions:
370            act = menu.addAction(session.name)
371            act.setCheckable(True)
372            act.setChecked(session.isActive)
373            act.setData(session.filePath)
374            actionGroup.addAction(act)
375            act.triggered.connect(
376                functools.partial(self.__sessionActTriggered, act))
377
378    @pyqtSlot()
379    def __sessionActTriggered(self, act):
380        """
381        Private slot to handle the menu selection of a session.
382
383        @param act reference to the action that triggered
384        @type QAction
385        """
386        path = act.data()
387        self.switchToSession(path)
388
389    def openSession(self, sessionFilePath, flags=0):
390        """
391        Public method to open a session from a given session file.
392
393        @param sessionFilePath name of the session file to get session from
394        @type str
395        @param flags flags determining the open mode
396        @type int
397        """
398        if self.__isActive(sessionFilePath):
399            return
400
401        sessionData = self.readSessionFromFile(sessionFilePath)
402        if not sessionData or not sessionData["Windows"]:
403            return
404
405        from WebBrowser.WebBrowserWindow import WebBrowserWindow
406        window = WebBrowserWindow.mainWindow()
407
408        if ((flags & SessionManager.SwitchSession) ==
409                SessionManager.SwitchSession):
410            # save the current session
411            self.writeCurrentSession(self.__lastActiveSession)
412
413            # create new window for the new session
414            window = window.newWindow(restoreSession=True)
415
416            # close all existing windows
417            for win in WebBrowserWindow.mainWindows()[:]:
418                if win is not window:
419                    win.forceClose()
420
421            if (
422                (flags & SessionManager.ReplaceSession) !=
423                SessionManager.ReplaceSession
424            ):
425                self.__lastActiveSession = (
426                    QFileInfo(sessionFilePath).canonicalFilePath()
427                )
428                self.__sessionMetaData = []
429
430        self.restoreSessionFromData(window, sessionData)
431
432    @classmethod
433    def restoreSessionFromData(cls, window=None, sessionData=None):
434        """
435        Class method to restore a session from a session data dictionary.
436
437        @param window reference to main window to restore to
438        @type WebBrowserWindow
439        @param sessionData dictionary containing the session data
440        """
441        from WebBrowser.WebBrowserWindow import WebBrowserWindow
442        if window is None:
443            window = WebBrowserWindow.mainWindow()
444
445        with E5OverrideCursor():
446            # restore session for first window
447            data = sessionData["Windows"].pop(0)
448            window.tabWidget().loadFromSessionData(data)
449            if "WindowGeometry" in data:
450                geometry = QByteArray.fromBase64(
451                    data["WindowGeometry"].encode("ascii"))
452                window.restoreGeometry(geometry)
453            QApplication.processEvents()
454
455            # restore additional windows
456            for data in sessionData["Windows"]:
457                window = (
458                    WebBrowserWindow.mainWindow().newWindow(
459                        restoreSession=True)
460                )
461                window.tabWidget().loadFromSessionData(data)
462                if "WindowGeometry" in data:
463                    geometry = QByteArray.fromBase64(
464                        data["WindowGeometry"].encode("ascii"))
465                    window.restoreGeometry(geometry)
466                QApplication.processEvents()
467
468        if "CurrentWindowIndex" in sessionData:
469            currentWindowIndex = sessionData["CurrentWindowIndex"]
470            with contextlib.suppress(IndexError):
471                currentWindow = (
472                    WebBrowserWindow.mainWindows()[currentWindowIndex]
473                )
474                QTimer.singleShot(0, lambda: currentWindow.raise_())
475
476    def renameSession(self, sessionFilePath, flags=0):
477        """
478        Public method to rename or clone a session.
479
480        @param sessionFilePath name of the session file
481        @type str
482        @param flags flags determining a rename or clone operation
483        @type int
484        """
485        from WebBrowser.WebBrowserWindow import WebBrowserWindow
486
487        suggestedName = QFileInfo(sessionFilePath).baseName()
488        if flags & SessionManager.CloneSession:
489            suggestedName += "_cloned"
490            title = self.tr("Clone Session")
491        else:
492            suggestedName += "_renamed"
493            title = self.tr("Rename Session")
494        newName, ok = QInputDialog.getText(
495            WebBrowserWindow.getWindow(),
496            title,
497            self.tr("Please enter a new name:"),
498            QLineEdit.EchoMode.Normal,
499            suggestedName)
500
501        if not ok:
502            return
503
504        if not newName.endswith(".json"):
505            newName += ".json"
506
507        newSessionPath = os.path.join(self.getSessionsDirectory(), newName)
508        if os.path.exists(newSessionPath):
509            E5MessageBox.information(
510                WebBrowserWindow.getWindow(),
511                title,
512                self.tr("""The session file "{0}" exists already. Please"""
513                        """ enter another name.""").format(newName))
514            self.renameSession(sessionFilePath, flags)
515            return
516
517        if flags & SessionManager.CloneSession:
518            if not QFile.copy(sessionFilePath, newSessionPath):
519                E5MessageBox.critical(
520                    WebBrowserWindow.getWindow(),
521                    title,
522                    self.tr("""An error occurred while cloning the session"""
523                            """ file."""))
524                return
525        else:
526            if not QFile.rename(sessionFilePath, newSessionPath):
527                E5MessageBox.critical(
528                    WebBrowserWindow.getWindow(),
529                    title,
530                    self.tr("""An error occurred while renaming the session"""
531                            """ file."""))
532                return
533            if self.__isActive(sessionFilePath):
534                self.__lastActiveSession = newSessionPath
535                self.__sessionMetaData = []
536
537    def saveSession(self):
538        """
539        Public method to save the current session.
540        """
541        from WebBrowser.WebBrowserWindow import WebBrowserWindow
542        newName, ok = QInputDialog.getText(
543            WebBrowserWindow.getWindow(),
544            self.tr("Save Session"),
545            self.tr("Please enter a name for the session:"),
546            QLineEdit.EchoMode.Normal,
547            self.tr("Saved Session ({0})").format(
548                QDateTime.currentDateTime().toString("yyyy-MM-dd HH-mm-ss")))
549
550        if not ok:
551            return
552
553        if not newName.endswith(".json"):
554            newName += ".json"
555
556        newSessionPath = os.path.join(self.getSessionsDirectory(), newName)
557        if os.path.exists(newSessionPath):
558            E5MessageBox.information(
559                WebBrowserWindow.getWindow(),
560                self.tr("Save Session"),
561                self.tr("""The session file "{0}" exists already. Please"""
562                        """ enter another name.""").format(newName))
563            self.saveSession()
564            return
565
566        self.writeCurrentSession(newSessionPath)
567
568    def replaceSession(self, sessionFilePath):
569        """
570        Public method to replace the current session with the given one.
571
572        @param sessionFilePath file name of the session file to replace with
573        @type str
574        @return flag indicating success
575        @rtype bool
576        """
577        from WebBrowser.WebBrowserWindow import WebBrowserWindow
578        res = E5MessageBox.yesNo(
579            WebBrowserWindow.getWindow(),
580            self.tr("Restore Backup"),
581            self.tr("""Are you sure you want to replace the current"""
582                    """ session?"""))
583        if res:
584            self.openSession(sessionFilePath, SessionManager.ReplaceSession)
585            return True
586        else:
587            return False
588
589    def switchToSession(self, sessionFilePath):
590        """
591        Public method to switch the current session to the given one.
592
593        @param sessionFilePath file name of the session file to switch to
594        @type str
595        @return flag indicating success
596        @rtype bool
597        """
598        self.openSession(sessionFilePath, SessionManager.SwitchSession)
599        return True
600
601    def cloneSession(self, sessionFilePath):
602        """
603        Public method to clone a session.
604
605        @param sessionFilePath file name of the session file to be cloned
606        @type str
607        """
608        self.renameSession(sessionFilePath, SessionManager.CloneSession)
609
610    def deleteSession(self, sessionFilePath):
611        """
612        Public method to delete a session.
613
614        @param sessionFilePath file name of the session file to be deleted
615        @type str
616        """
617        from WebBrowser.WebBrowserWindow import WebBrowserWindow
618        res = E5MessageBox.yesNo(
619            WebBrowserWindow.getWindow(),
620            self.tr("Delete Session"),
621            self.tr("""Are you sure you want to delete session "{0}"?""")
622            .format(QFileInfo(sessionFilePath).baseName()))
623        if res:
624            QFile.remove(sessionFilePath)
625
626    def newSession(self):
627        """
628        Public method to start a new session.
629        """
630        from WebBrowser.WebBrowserWindow import WebBrowserWindow
631        newName, ok = QInputDialog.getText(
632            WebBrowserWindow.getWindow(),
633            self.tr("New Session"),
634            self.tr("Please enter a name for the new session:"),
635            QLineEdit.EchoMode.Normal,
636            self.tr("New Session ({0})").format(
637                QDateTime.currentDateTime().toString("yyyy-MM-dd HH-mm-ss")))
638
639        if not ok:
640            return
641
642        if not newName.endswith(".json"):
643            newName += ".json"
644
645        newSessionPath = os.path.join(self.getSessionsDirectory(), newName)
646        if os.path.exists(newSessionPath):
647            E5MessageBox.information(
648                WebBrowserWindow.getWindow(),
649                self.tr("New Session"),
650                self.tr("""The session file "{0}" exists already. Please"""
651                        """ enter another name.""").format(newName))
652            self.newSession()
653            return
654
655        self.writeCurrentSession(self.__lastActiveSession)
656
657        # create new window for the new session and close all existing windows
658        window = WebBrowserWindow.mainWindow().newWindow()
659        for win in WebBrowserWindow.mainWindows():
660            if win is not window:
661                win.forceClose()
662
663        self.__lastActiveSession = newSessionPath
664        self.__autoSaveSession()
665
666    def showSessionManagerDialog(self):
667        """
668        Public method to show the session manager dialog.
669        """
670        from WebBrowser.WebBrowserWindow import WebBrowserWindow
671        from .SessionManagerDialog import SessionManagerDialog
672
673        dlg = SessionManagerDialog(WebBrowserWindow.getWindow())
674        dlg.open()
675
676    def selectSession(self):
677        """
678        Public method to select a session to be restored.
679
680        @return name of the session file to be restored
681        @rtype str
682        """
683        from WebBrowser.WebBrowserWindow import WebBrowserWindow
684
685        self.__fillMetaDataList()
686
687        if self.__sessionMetaData:
688            # skip, if no session file available
689            dlg = QDialog(WebBrowserWindow.getWindow(),
690                          Qt.WindowType.WindowStaysOnTopHint)
691            lbl = QLabel(self.tr("Please select the startup session:"))
692            combo = QComboBox(dlg)
693            buttonBox = QDialogButtonBox(
694                QDialogButtonBox.StandardButton.Ok |
695                QDialogButtonBox.StandardButton.Cancel,
696                dlg)
697            buttonBox.accepted.connect(dlg.accept)
698            buttonBox.rejected.connect(dlg.reject)
699
700            layout = QVBoxLayout()
701            layout.addWidget(lbl)
702            layout.addWidget(combo)
703            layout.addWidget(buttonBox)
704            dlg.setLayout(layout)
705
706            lastActiveSessionFileInfo = QFileInfo(self.__lastActiveSession)
707
708            for metaData in self.__sessionMetaData:
709                if QFileInfo(metaData.filePath) != lastActiveSessionFileInfo:
710                    combo.addItem(metaData.name, metaData.filePath)
711                else:
712                    combo.insertItem(
713                        0,
714                        self.tr("{0} (last session)").format(metaData.name),
715                        metaData.filePath
716                    )
717            combo.setCurrentIndex(0)
718
719            if dlg.exec() == QDialog.DialogCode.Accepted:
720                session = combo.currentData()
721                if session is None:
722                    self.__lastActiveSession = self.__sessionDefault
723                else:
724                    self.__lastActiveSession = session
725
726        return self.__lastActiveSession
727