From 407e7fa69e401ccea1c86de84657d6e6ecdcccd3 Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Sat, 5 Nov 2022 16:15:26 +0100 Subject: [PATCH] Add storage code to handle projecty structure and old projects --- CHANGELOG.md | 8 +- novelwriter/core/project.py | 51 ++-------- novelwriter/core/storage.py | 147 +++++++++++++++++++++++++-- tests/test_core/test_core_project.py | 10 -- 4 files changed, 154 insertions(+), 62 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 06f51d75..8f15afd2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3707,8 +3707,8 @@ helpful feedback and issue reports for the new features added in this, and previ **User Interface** -* Added a preferences dialog for the program settings. No longer necessary to edit the config file. - PR #30. +* Added a preferences dialog for the program settings. It is no longer necessary to edit the config + file. PR #30. * The document viewer remembers scroll bar position when pressing `Ctrl+R` on a document already being viewed. PR #28. * Removed version number from windows title. PR #28. @@ -3749,8 +3749,8 @@ helpful feedback and issue reports for the new features added in this, and previ **Status Bar** * Redesign of the status bar adding project and session stats as well as a session timer. PR #21. -* Project word count is written to the project file, which is needed for the session word count. PR - #21. +* Project word count is written to the project file, which is needed for the session word count. + PR #21. * Closing a project now clears the status bar. PR #21. **Editor** diff --git a/novelwriter/core/project.py b/novelwriter/core/project.py index 1714cfd2..00a01ff6 100644 --- a/novelwriter/core/project.py +++ b/novelwriter/core/project.py @@ -264,42 +264,26 @@ class NWProject(QObject): return - def openProject(self, fileName, overrideLock=False): + def openProject(self, projPath, overrideLock=False): """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. """ - if not os.path.isfile(fileName): - fileName = os.path.join(fileName, nwFiles.PROJ_FILE) - if not os.path.isfile(fileName): - self.mainGui.makeAlert(self.tr( - "File not found: {0}" - ).format(fileName), nwAlert.ERROR) - return False - self.clearProject() - self.projPath = os.path.abspath(os.path.dirname(fileName)) - logger.info("Opening project: %s", self.projPath) - - # Standard Folders and Files - # ========================== - - if not self.ensureFolderStructure(): - self.clearProject() + if not self._storage.openProjectInPlace(projPath): return False + # ToDo: These should not be set explicitly, and should stay as Path + self.projPath = str(self._storage.runtimePath) + self.projContent = str(self._storage.contentPath) + self.projCache = str(self._storage.cachePath) + self.projMeta = str(self._storage.metaPath) + + logger.info("Opening project: %s", self.projPath) + self.projDict = os.path.join(self.projMeta, nwFiles.PROJ_DICT) - # Check for Old Legacy Data - # ========================= - - legacyList = [] # Cleanup is done later - for projItem in os.listdir(self.projPath): - logger.debug("Project contains: %s", projItem) - if projItem.startswith("data_") and len(projItem) == 6: - legacyList.append(projItem) - # Project Lock # ============ @@ -321,10 +305,6 @@ class NWProject(QObject): # Open The Project XML File # ========================= - if not self._storage.openProjectInPlace(self.projPath): - self.clearProject() - return False - xmlReader = self._storage.getXmlReader() if not isinstance(xmlReader, ProjectXMLReader): self.clearProject() @@ -398,17 +378,6 @@ class NWProject(QObject): self._options.loadSettings() self._index.loadIndex() - # Sort out old file locations - if legacyList: - try: - for projItem in legacyList: - self._legacyDataFolder(projItem) - except Exception: - self.mainGui.makeAlert(self.tr( - "There was an error updating the project. " - "Some data may not have been preserved." - ), nwAlert.ERROR) - # Clean up no longer used files self._deprecatedFiles() diff --git a/novelwriter/core/storage.py b/novelwriter/core/storage.py index 43e5d618..dfa4a2a2 100644 --- a/novelwriter/core/storage.py +++ b/novelwriter/core/storage.py @@ -57,6 +57,32 @@ class NWStorage: self._openMode = self.MODE_INACTIVE return + ## + # Properties + ## + + @property + def runtimePath(self): + return self._runtimePath + + @property + def contentPath(self): + if self._runtimePath is not None: + return self._runtimePath / "content" + return None + + @property + def metaPath(self): + if self._runtimePath is not None: + return self._runtimePath / "meta" + return None + + @property + def cachePath(self): + if self._runtimePath is not None: + return self._runtimePath / "cache" + return None + ## # Core Methods ## @@ -70,19 +96,20 @@ class NWStorage: """Open a novelWriter project in-place. That is, it is opened directly from a project folder. """ - inPath = Path(path) + inPath = Path(path).resolve() if inPath.is_file(): + # The path should not point to an exisitng file, + # but it can point to a folder containing files inPath = inPath.parent - if not inPath.is_dir(): - logger.error("No such folder: %s", inPath) - self.clear() - return False - self._storagePath = inPath self._runtimePath = inPath self._openMode = self.MODE_INPLACE + if self._prepareStorage(checkLegacy=True) is False: + self.clear() + return False + return True def openProjectArchive(self, path): @@ -142,10 +169,116 @@ class NWStorage: def _zipIt(self, target): pass - def _reeadLockFile(self): + def _readLockFile(self): pass def _writeLockFile(self): pass + def _prepareStorage(self, checkLegacy=True): + """Prepare the storage area for the project. + """ + path = self._runtimePath + if path is None: + logger.error("No path set") + self.clear() + return False + + if path == Path.home().absolute(): + logger.error("Cannot use the user's home path as the root of a project") + self.clear() + return False + + # The folder is not required to exist, as it could be a new + # project, so we make sure it does. Then we add subfolders. + try: + path.mkdir(exist_ok=True) + (path / "content").mkdir(exist_ok=True) + (path / "cache").mkdir(exist_ok=True) + (path / "meta").mkdir(exist_ok=True) + except Exception as exc: + logger.error("Failed to create required project folders", exc_info=exc) + return False + + if not checkLegacy: + # The legacy content check is only needed for project folder + # storage, so if it is not expected to be that, there's no + # need for the remaning checks. + return True + + # Check for legacy data folders + for child in path.iterdir(): + if child.is_dir() and child.name.startswith("data_"): + self._legacyDataFolder(path, child) + + # Check for no longer used files, and delete them + self._deleteDeprecatedFiles(path) + + return True + + ## + # Legacy Project Data Handlers + ## + + def _legacyDataFolder(self, path: Path, child: Path): + """Handle the content of a legacy data folder from a version 1.0 + project. + """ + logger.info("Processing legacy data folder: %s", path) + + # Move Documents to Content + first = child.name[-1] + if first not in "0123456789abcdef": + return + + for item in child.iterdir(): + if not item.is_file(): + continue + + name = item.name + if len(name) == 21 and name.endswith("_main.nwd"): + newPath = path / "content" / f"{first}{name[:12]}.nwd" + try: + item.rename(newPath) + logger.info("Moved file: %s", newPath) + except Exception as exc: + logger.warning("Failed to move: %s", item, exc_info=exc) + elif len(name) == 21 and name.endswith("_main.bak"): + try: + item.unlink() + logger.info("Deleted file: %s", item) + except Exception as exc: + logger.warning("Failed to delete: %s", item, exc_info=exc) + + # Remove Data Folder + try: + child.rmdir() + logger.info("Deleted folder: %s", child) + except Exception as exc: + logger.warning("Failed to delete: %s", child, exc_info=exc) + + return + + def _deleteDeprecatedFiles(self, path: Path): + """Delete files that are no longer used by novelWriter. + """ + remove = [ + path / "meta" / "mainOptions.json", + path / "meta" / "exportOptions.json", + path / "meta" / "outlineOptions.json", + path / "meta" / "timelineOptions.json", + path / "meta" / "docMergeOptions.json", + path / "meta" / "sessionLogOptions.json", + path / "ToC.json", + ] + for item in remove: + if item.is_file(): + try: + item.unlink() + logger.info("Deleted: %s", item) + except Exception as exc: + logger.warning("Failed to delete: %s", item, exc_info=exc) + + return + # END Class NWStorage diff --git a/tests/test_core/test_core_project.py b/tests/test_core/test_core_project.py index 9d7b12e5..29e1a561 100644 --- a/tests/test_core/test_core_project.py +++ b/tests/test_core/test_core_project.py @@ -250,16 +250,6 @@ def testCoreProject_Open(monkeypatch, caplog, mockGUI, fncDir, mockRnd): assert "This project was saved by a newer version" in mockGUI.lastQuestion[1] mockGUI.askResponse = True - # Add some legacy stuff that cannot be removed - with monkeypatch.context() as mp: - mp.setattr(theProject, "_legacyDataFolder", causeOSError) - os.mkdir(os.path.join(fncDir, "data_0")) - writeFile(os.path.join(fncDir, "data_0", "123456789abc_main.nwd"), "stuff") - writeFile(os.path.join(fncDir, "data_0", "123456789abc_main.bak"), "stuff") - mockGUI.clear() - assert theProject.openProject(fncDir) is True - assert "There was an error updating the project." in mockGUI.lastAlert - assert theProject.closeProject() # END Test testCoreProject_Open