From 150f6603cdf968d543a538c047b288597e0b1d0a Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Sun, 13 Aug 2023 20:16:37 +0200 Subject: [PATCH] Let the shared class handle project open/save/close and recreating the project instance --- novelwriter/core/project.py | 76 ++++++--------------- novelwriter/dialogs/projdetails.py | 7 +- novelwriter/gui/projtree.py | 2 +- novelwriter/guimain.py | 14 ++-- novelwriter/shared.py | 37 ++++++++-- novelwriter/tools/manussettings.py | 2 +- tests/test_core/test_core_project.py | 11 +-- tests/test_gui/test_gui_guimain.py | 6 +- tests/test_tools/test_tools_writingstats.py | 9 +-- tests/tools.py | 1 - 10 files changed, 79 insertions(+), 86 deletions(-) diff --git a/novelwriter/core/project.py b/novelwriter/core/project.py index de2b4814..2164d4e2 100644 --- a/novelwriter/core/project.py +++ b/novelwriter/core/project.py @@ -129,8 +129,21 @@ class NWProject(QObject): @property def isValid(self) -> bool: + """Return True if a project is loaded.""" return self._valid + @property + def lockStatus(self) -> list | None: + """Return the project lock information.""" + if isinstance(self._lockedBy, list) and len(self._lockedBy) == 4: + return self._lockedBy + return None + + @property + def currentEditTime(self) -> int: + """Return total edit time, including the current session.""" + return self._data.editTime + round(time() - self._session.start) + ## # Item Methods ## @@ -206,35 +219,12 @@ class NWProject(QObject): # Project Methods ## - def clearProject(self) -> None: - """Clear the data for the current project, and set them to - default values. - - Note: Don't clear the lockedBy data here as it is needed after - this function is called. - """ - # Core Elements - self._options = OptionState(self) - self._storage.clear() - self._data = NWProjectData(self) - self._tree.clear() - self._index.clearIndex() - self._session = NWSessionLog(self) - - # Project Status - self._langData = {} - self._changed = False - self._valid = False - - return - - def openProject(self, projPath: str | Path, overrideLock: bool = False) -> bool: + def openProject(self, projPath: str | Path) -> bool: """Open the project file provided. If it doesn't exist, assume it is a folder and look for the file within it. If successful, parse the XML of the file and populate the project variables and build the tree of project items. """ - self.clearProject() logger.info("Opening project: %s", projPath) if not self._storage.openProjectInPlace(projPath): self.mainGui.makeAlert(self.tr( @@ -245,9 +235,6 @@ class NWProject(QObject): # Project Lock # ============ - if overrideLock: - self._storage.clearLockFile() - lockStatus = self._storage.readLockFile() if len(lockStatus) > 0: if lockStatus[0] == "ERROR": @@ -255,7 +242,6 @@ class NWProject(QObject): else: logger.error("Project is locked, so not opening") self._lockedBy = lockStatus - self.clearProject() return False else: logger.debug("Project is not locked") @@ -265,7 +251,6 @@ class NWProject(QObject): xmlReader = self._storage.getXmlReader() if not isinstance(xmlReader, ProjectXMLReader): - self.clearProject() return False self._data = NWProjectData(self) @@ -289,8 +274,6 @@ class NWProject(QObject): self.mainGui.makeAlert(self.tr( "Failed to parse project xml." ), level=nwAlert.ERROR) - - self.clearProject() return False # Check Legacy Upgrade @@ -303,7 +286,6 @@ class NWProject(QObject): "longer be able to open this project. Continue?" )) if not msgYes: - self.clearProject() return False # Check novelWriter Version @@ -318,7 +300,6 @@ class NWProject(QObject): "should be fine. Continue opening the project?" ).format(appVersion, __version__)) if not msgYes: - self.clearProject() return False # Extract Data @@ -329,9 +310,11 @@ class NWProject(QObject): self._loadProjectLocalisation() # Update recent projects - CONFIG.recentProjects.update( - self._storage.storagePath, self._data.name, sum(self._data.initCounts), time() - ) + storePath = self._storage.storagePath + if storePath: + CONFIG.recentProjects.update( + storePath, self._data.name, sum(self._data.initCounts), time() + ) # Check the project tree consistency # This also handles any orphaned files found @@ -416,7 +399,6 @@ class NWProject(QObject): self._tree.writeToCFile() self._session.appendSession(idleTime) self._storage.closeSession() - self.clearProject() self._lockedBy = None return @@ -522,22 +504,10 @@ class NWProject(QObject): return self._changed ## - # Getters + # Class Methods ## - def getLockStatus(self) -> list | None: - """Return the project lock information for the project.""" - if isinstance(self._lockedBy, list) and len(self._lockedBy) == 4: - return self._lockedBy - return None - - def getCurrentEditTime(self) -> int: - """Get the total project edit time, including the time spent in - the current session. - """ - return self._data.editTime + round(time() - self._session.start) - - def getProjectItems(self) -> Iterator[NWItem]: + def iterProjectItems(self) -> Iterator[NWItem]: """This function ensures that the item tree loaded is sent to the GUI tree view in such a way that the tree can be built. That is, the parent item must be sent before its child. In principle, @@ -580,10 +550,6 @@ class NWProject(QObject): yield tItem return - ## - # Class Methods - ## - def updateWordCounts(self) -> None: """Update the total word count values.""" novel, notes = self._tree.sumWords() diff --git a/novelwriter/dialogs/projdetails.py b/novelwriter/dialogs/projdetails.py index c7ceacaf..f5415267 100644 --- a/novelwriter/dialogs/projdetails.py +++ b/novelwriter/dialogs/projdetails.py @@ -237,14 +237,13 @@ class GuiProjectDetailsMain(QWidget): return - def updateValues(self): - """Set all the values. - """ + def updateValues(self) -> None: + """Set all the values.""" project = SHARED.project pIndex = project.index hCounts = pIndex.getNovelTitleCounts() nwCount = pIndex.getNovelWordCount() - edTime = project.getCurrentEditTime() + edTime = project.currentEditTime self.bookTitle.setText(project.data.title or project.data.name) self.projName.setText(self.tr("Project: {0}").format(project.data.name)) diff --git a/novelwriter/gui/projtree.py b/novelwriter/gui/projtree.py index 61fa87d4..43b26631 100644 --- a/novelwriter/gui/projtree.py +++ b/novelwriter/gui/projtree.py @@ -1064,7 +1064,7 @@ class GuiProjectTree(QTreeWidget): logger.debug("Building the project tree ...") self.clearTree() count = 0 - for nwItem in SHARED.project.getProjectItems(): + for nwItem in SHARED.project.iterProjectItems(): count += 1 self._addTreeItem(nwItem) if count > 0: diff --git a/novelwriter/guimain.py b/novelwriter/guimain.py index 537fa33f..f7f2ffdd 100644 --- a/novelwriter/guimain.py +++ b/novelwriter/guimain.py @@ -454,9 +454,7 @@ class GuiMain(QMainWindow): if SHARED.project.data.doBackup and CONFIG.backupOnClose: doBackup = True if CONFIG.askBeforeBackup: - msgYes = self.askQuestion(self.tr("Backup the current project?")) - if not msgYes: - doBackup = False + doBackup = self.askQuestion(self.tr("Backup the current project?")) if doBackup: SHARED.project.backupProject(False) @@ -490,9 +488,9 @@ class GuiMain(QMainWindow): self._changeView(nwView.PROJECT) # Try to open the project - if not SHARED.project.openProject(projFile): + if not SHARED.openProject(projFile): # The project open failed. - lockStatus = SHARED.project.getLockStatus() + lockStatus = SHARED.projectLock if lockStatus is None: # The project is not locked, so failed for some other # reason handled by the project class. @@ -522,7 +520,8 @@ class GuiMain(QMainWindow): lockDetails = "" if self.askQuestion(lockText, info=lockInfo, details=lockDetails, level=nwAlert.WARN): - if not SHARED.project.openProject(projFile, overrideLock=True): + SHARED.unlockProject() + if not SHARED.openProject(projFile): return False else: return False @@ -578,8 +577,7 @@ class GuiMain(QMainWindow): logger.error("No project open") return False self.projView.saveProjectTasks() - SHARED.project.saveProject(autoSave=autoSave) - return True + return SHARED.saveProject(autoSave=autoSave) ## # Document Actions diff --git a/novelwriter/shared.py b/novelwriter/shared.py index 13a83935..d05b2ce5 100644 --- a/novelwriter/shared.py +++ b/novelwriter/shared.py @@ -42,6 +42,7 @@ class SharedData: self._gui = None self._theme = None self._project = None + self._lockedBy = None return @property @@ -69,6 +70,10 @@ class SharedData: def hasProject(self) -> bool: return self.project.isValid + @property + def projectLock(self) -> list | None: + return self._lockedBy + ## # Methods ## @@ -84,16 +89,38 @@ class SharedData: logger.debug("SharedData instance initialised") return - def openProject(self, path: str | Path) -> None: - return + def openProject(self, path: str | Path) -> bool: + """Open a project.""" + if self.project.isValid: + logger.error("A project is already open") + return False - def saveProject(self): - return + self._lockedBy = None + status = self.project.openProject(path) + if status is False: + # We must cache the lock status before resetting the project + self._lockedBy = self.project.lockStatus + self._resetProject() + + return status + + def saveProject(self, autoSave: bool = False) -> bool: + """Save the current project.""" + if not self.project.isValid: + logger.error("There is no project open") + return False + return self.project.saveProject(autoSave=autoSave) def closeProject(self, idleTime: float) -> None: + """Close the current project.""" self.project.closeProject(idleTime) + self._resetProject() return + def unlockProject(self) -> bool: + """Remove the project lock.""" + return self.project.storage.clearLockFile() + ## # Internal Functions ## @@ -101,6 +128,8 @@ class SharedData: def _resetProject(self) -> None: """Create a new project instance.""" from novelwriter.core.project import NWProject + if isinstance(self._project, NWProject): + self._project.deleteLater() self._project = NWProject(self.mainGui) return diff --git a/novelwriter/tools/manussettings.py b/novelwriter/tools/manussettings.py index 44ac3fce..dc7195f6 100644 --- a/novelwriter/tools/manussettings.py +++ b/novelwriter/tools/manussettings.py @@ -443,7 +443,7 @@ class _FilterTab(QWidget): logger.debug("Building project tree") self._treeMap = {} self.optTree.clear() - for nwItem in SHARED.project.getProjectItems(): + for nwItem in SHARED.project.iterProjectItems(): tHandle = nwItem.itemHandle pHandle = nwItem.itemParent diff --git a/tests/test_core/test_core_project.py b/tests/test_core/test_core_project.py index b13b75c4..a14f5703 100644 --- a/tests/test_core/test_core_project.py +++ b/tests/test_core/test_core_project.py @@ -176,7 +176,7 @@ def testCoreProject_Open(monkeypatch, caplog, mockGUI, fncPath, mockRnd): theProject.storage._lockFilePath = fncPath / nwFiles.PROJ_LOCK assert theProject.storage.writeLockFile() is True assert theProject.openProject(fncPath) is False - assert isinstance(theProject.getLockStatus(), list) + assert isinstance(theProject.lockStatus, list) # Fail to read lockfile (which still opens the project) with monkeypatch.context() as mp: @@ -189,9 +189,10 @@ def testCoreProject_Open(monkeypatch, caplog, mockGUI, fncPath, mockRnd): # Force open with lockfile theProject.storage._lockFilePath = fncPath / nwFiles.PROJ_LOCK assert theProject.storage.writeLockFile() is True - assert theProject.openProject(fncPath, overrideLock=True) is True + theProject.storage.clearLockFile() + assert theProject.openProject(fncPath) is True theProject.closeProject() - assert theProject.getLockStatus() is None + assert theProject.lockStatus is None # Fail getting xml reader with monkeypatch.context() as mp: @@ -331,7 +332,7 @@ def testCoreProject_AccessItems(mockGUI, fncPath, mockRnd): assert theProject.tree[nHandle].itemParent == "cba9876543210" retOrder = [] - for tItem in theProject.getProjectItems(): + for tItem in theProject.iterProjectItems(): retOrder.append(tItem.itemHandle) assert retOrder == [ @@ -482,7 +483,7 @@ def testCoreProject_Methods(monkeypatch, mockGUI, fncPath, mockRnd): theProject._session._start = 1600000000 with monkeypatch.context() as mp: mp.setattr("novelwriter.core.project.time", lambda: 1600005600) - assert theProject.getCurrentEditTime() == 6834 + assert theProject.currentEditTime == 6834 # Trash folder # Should create on first call, and just returned on later calls diff --git a/tests/test_gui/test_gui_guimain.py b/tests/test_gui/test_gui_guimain.py index 7df548d4..e87ed70d 100644 --- a/tests/test_gui/test_gui_guimain.py +++ b/tests/test_gui/test_gui_guimain.py @@ -104,9 +104,8 @@ def testGuiMain_Launch(qtbot, monkeypatch, nwGUI, prjLipsum): @pytest.mark.gui def testGuiMain_NewProject(monkeypatch, nwGUI, projPath): - """Test creating a new project. - """ - # No data + """Test creating a new project.""" + # Open wizard, but return no data with monkeypatch.context() as mp: mp.setattr(GuiProjectWizard, "exec_", lambda *a: None) assert nwGUI.newProject(projData=None) is False @@ -116,6 +115,7 @@ def testGuiMain_NewProject(monkeypatch, nwGUI, projPath): SHARED.project._valid = True mp.setattr(QMessageBox, "result", lambda *a: QMessageBox.No) assert nwGUI.newProject(projData={"projPath": projPath}) is False + SHARED.project._valid = False # No project path assert nwGUI.newProject(projData={}) is False diff --git a/tests/test_tools/test_tools_writingstats.py b/tests/test_tools/test_tools_writingstats.py index 3674004c..50d78f2a 100644 --- a/tests/test_tools/test_tools_writingstats.py +++ b/tests/test_tools/test_tools_writingstats.py @@ -377,13 +377,14 @@ def testToolWritingStats_Main(qtbot, monkeypatch, nwGUI, projPath, tstPaths): # IOError # ======= - monkeypatch.setattr("builtins.open", causeOSError) - assert not sessLog._loadLogFile() - assert not sessLog._saveData(sessLog.FMT_CSV) + with monkeypatch.context() as mp: + mp.setattr("builtins.open", causeOSError) + assert not sessLog._loadLogFile() + assert not sessLog._saveData(sessLog.FMT_CSV) # qtbot.stop() sessLog._doClose() - assert nwGUI.closeProject() + assert nwGUI.closeProject() is True # END Test testToolWritingStats_Main diff --git a/tests/tools.py b/tests/tools.py index 9b896795..f96b5259 100644 --- a/tests/tools.py +++ b/tests/tools.py @@ -168,7 +168,6 @@ def buildTestProject(obj, projPath): nwGUI = obj project = obj.project - project.clearProject() project.storage.openProjectInPlace(projPath) project.setDefaultStatusImport()