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