diff --git a/novelwriter/core/coretools.py b/novelwriter/core/coretools.py index bacf9e7c..1ca74606 100644 --- a/novelwriter/core/coretools.py +++ b/novelwriter/core/coretools.py @@ -340,7 +340,7 @@ class ProjectBuilder: return False project = NWProject() - if not project.storage.openProjectInPlace(projPath, newProject=True): + if not project.storage.createNewProject(projPath): return False lblNewProject = self.tr("New Project") diff --git a/novelwriter/core/project.py b/novelwriter/core/project.py index 1f74bb43..be1d3149 100644 --- a/novelwriter/core/project.py +++ b/novelwriter/core/project.py @@ -26,6 +26,7 @@ from __future__ import annotations import json import logging +from enum import Enum from time import time from typing import TYPE_CHECKING, Iterator from pathlib import Path @@ -40,7 +41,7 @@ from novelwriter.constants import trConst, nwLabels from novelwriter.core.tree import NWTree from novelwriter.core.index import NWIndex from novelwriter.core.options import OptionState -from novelwriter.core.storage import NWStorage +from novelwriter.core.storage import NWStorage, NWStorageOpen from novelwriter.core.sessions import NWSessionLog from novelwriter.core.projectxml import ProjectXMLReader, ProjectXMLWriter, XMLReadState from novelwriter.core.projectdata import NWProjectData @@ -55,6 +56,16 @@ if TYPE_CHECKING: # pragma: no cover logger = logging.getLogger(__name__) +class NWProjectState(Enum): + + UNKNOWN = 0 + LOCKED = 1 + RECOVERY = 2 + READY = 3 + +# END Enum NWProjectState + + class NWProject: def __init__(self) -> None: @@ -69,9 +80,9 @@ class NWProject: # Project Status self._langData = {} # Localisation data - self._lockedBy = None # Data on which computer has the project open self._changed = False # The project has unsaved changes self._valid = False # The project was successfully loaded + self._state = NWProjectState.UNKNOWN # Internal Mapping self.tr = partial(QCoreApplication.translate, "NWProject") @@ -125,12 +136,15 @@ class NWProject: """Return True if a project is loaded.""" return self._valid + @property + def state(self) -> NWProjectState: + """Return the current project state.""" + return self._state + @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 + return self._storage.lockStatus @property def currentEditTime(self) -> int: @@ -219,29 +233,23 @@ class NWProject: build the tree of project items. """ logger.info("Opening project: %s", projPath) - if not self._storage.openProjectInPlace(projPath): - SHARED.error(self.tr("Could not open project with path: {0}").format(projPath)) + + status = self._storage.initProjectStorage(projPath, clearLock) + if status != NWStorageOpen.READY: + if status == NWStorageOpen.UNKOWN: + SHARED.error(self.tr("Not a known project file format.")) + elif status == NWStorageOpen.NOT_FOUND: + SHARED.error(self.tr("Project file not found.")) + elif status == NWStorageOpen.LOCKED: + self._state = NWProjectState.LOCKED + elif status == NWStorageOpen.RECOVERY: + self._state = NWProjectState.RECOVERY + elif status == NWStorageOpen.FAILED: + SHARED.error(self.tr("Failed to open project."), exc=self._storage.exc) return False - # Project Lock - # ============ - - if clearLock: - self._storage.clearLockFile() - - lockStatus = self._storage.readLockFile() - if len(lockStatus) > 0: - if lockStatus[0] == "ERROR": - logger.warning("Failed to check lock file") - else: - logger.error("Project is locked, so not opening") - self._lockedBy = lockStatus - return False - else: - logger.debug("Project is not locked") - - # Open The Project XML File - # ========================= + # Read Project XML + # ================ xmlReader = self._storage.getXmlReader() if not isinstance(xmlReader, ProjectXMLReader): @@ -250,9 +258,7 @@ class NWProject: self._data = NWProjectData(self) projContent = [] xmlParsed = xmlReader.read(self._data, projContent) - appVersion = xmlReader.appVersion or self.tr("Unknown") - if not xmlParsed: if xmlReader.state == XMLReadState.NOT_NWX_FILE: SHARED.error(self.tr( @@ -323,9 +329,9 @@ class NWProject: self.updateWordCounts() self._session.startSession() - self._storage.writeLockFile() self.setProjectChanged(False) self._valid = True + self._state = NWProjectState.READY SHARED.newStatusMessage(self.tr("Opened Project: {0}").format(self._data.name)) @@ -370,13 +376,11 @@ class NWProject: self._storage.runPostSaveTasks(autoSave=autoSave) # Update recent projects - storePath = self._storage.storagePath - if storePath: + if storagePath := self._storage.storagePath: CONFIG.recentProjects.update( - storePath, self._data.name, sum(self._data.currCounts), saveTime + storagePath, self._data.name, sum(self._data.currCounts), saveTime ) - self._storage.writeLockFile() SHARED.newStatusMessage(self.tr("Saved Project: {0}").format(self._data.name)) self.setProjectChanged(False) @@ -420,8 +424,8 @@ class NWProject: timeStamp = formatTimeStamp(time(), fileSafe=True) archName = baseDir / f"{cleanName} {timeStamp}.zip" if self._storage.zipIt(archName, compression=2): - size = formatInt(getFileSize(archName)) if doNotify: + size = formatInt(getFileSize(archName)) SHARED.info( self.tr("Created a backup of your project of size {0}B.").format(size), info=self.tr("Path: {0}").format(str(backupPath)) diff --git a/novelwriter/core/projectxml.py b/novelwriter/core/projectxml.py index c12e0dc0..3ef165d7 100644 --- a/novelwriter/core/projectxml.py +++ b/novelwriter/core/projectxml.py @@ -38,7 +38,6 @@ from novelwriter.common import ( checkBool, checkInt, checkString, checkStringNone, formatTimeStamp, hexToInt, simplified, xmlIndent, yesNo ) -from novelwriter.constants import nwFiles if TYPE_CHECKING: # pragma: no cover from novelwriter.core.status import NWStatus @@ -558,9 +557,8 @@ class ProjectXMLWriter: xName.text = item["name"] # Write the XML tree to file - saveFile = self._path / nwFiles.PROJ_FILE - tempFile = saveFile.with_suffix(".tmp") - backFile = saveFile.with_suffix(".bak") + tempFile = self._path.with_suffix(".tmp") + backFile = self._path.with_suffix(".bak") try: xml = ET.ElementTree(xRoot) xmlIndent(xml) @@ -572,9 +570,9 @@ class ProjectXMLWriter: # If we're here, the file was successfully saved, # so let's sort out the temps and backups try: - if saveFile.exists(): - saveFile.replace(backFile) - tempFile.replace(saveFile) + if self._path.exists(): + self._path.replace(backFile) + tempFile.replace(self._path) except Exception as exc: self._error = exc return False diff --git a/novelwriter/core/storage.py b/novelwriter/core/storage.py index d8740c0e..5d0a74a5 100644 --- a/novelwriter/core/storage.py +++ b/novelwriter/core/storage.py @@ -26,6 +26,7 @@ from __future__ import annotations import json import logging +from enum import Enum from time import time from typing import TYPE_CHECKING from pathlib import Path @@ -45,6 +46,18 @@ if TYPE_CHECKING: # pragma: no cover logger = logging.getLogger(__name__) +class NWStorageOpen(Enum): + + UNKOWN = 0 + NOT_FOUND = 1 + LOCKED = 2 + RECOVERY = 3 + FAILED = 4 + READY = 5 + +# END Enum NWStorageOpen + + class NWStorage: """Core: Project Storage Class @@ -60,7 +73,10 @@ class NWStorage: self._storagePath = None self._runtimePath = None self._lockFilePath = None + self._lockedBy = None self._openMode = self.MODE_INACTIVE + self._ready = False + self._exception = None return def clear(self) -> None: @@ -69,6 +85,8 @@ class NWStorage: self._runtimePath = None self._lockFilePath = None self._openMode = self.MODE_INACTIVE + self._ready = False + self._exception = None return ## @@ -77,12 +95,12 @@ class NWStorage: @property def storagePath(self) -> Path | None: - """Get the path where the project is saved.""" + """Return the path where the project is stored.""" return self._storagePath @property def runtimePath(self) -> Path | None: - """Get the path where the project is saved at runtime.""" + """Return the path where the project is stored at runtime.""" return self._runtimePath @property @@ -97,6 +115,18 @@ class NWStorage: logger.error("Content path cannot be resolved") return None + @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 exc(self) -> Exception | None: + """Return the latest exception of the storage instance.""" + return self._exception + ## # Core Methods ## @@ -105,18 +135,11 @@ class NWStorage: """Check if the storage location is open.""" return self._runtimePath is not None - def openProjectInPlace(self, path: str | Path, newProject: bool = False) -> bool: - """Open a novelWriter project in-place. That is, it is opened - directly from a project folder. - """ + def createNewProject(self, path: str | Path) -> bool: + """Create a new project at the given location.""" inPath = Path(path).resolve() - if inPath.is_file(): - # The path should not point to an existing file, - # but it can point to a folder containing files - inPath = inPath.parent - - if not (inPath.is_dir() or newProject): - # If the project is not new, the folder must already exist. + if inPath.is_dir() and len(list(inPath.iterdir())) > 0: + logger.error("Folder is not empty: %s", inPath) return False self._storagePath = inPath @@ -124,17 +147,93 @@ class NWStorage: self._lockFilePath = inPath / nwFiles.PROJ_LOCK self._openMode = self.MODE_INPLACE - if not self._prepareStorage(checkLegacy=True, newProject=newProject): + basePath = self._runtimePath + metaPath = basePath / "meta" + contPath = basePath / "content" + try: + basePath.mkdir(exist_ok=True) + metaPath.mkdir(exist_ok=True) + contPath.mkdir(exist_ok=True) + except Exception as exc: + logger.error("Failed to create project folders", exc_info=exc) self.clear() return False + self._ready = True + return True - def openProjectArchive(self, path: str | Path) -> bool: # pragma: no cover - """Open the project from a single file. - Placeholder for later implementation. See #977. - """ - return False + def initProjectStorage(self, path: str | Path, clearLock: bool = False) -> NWStorageOpen: + """Initialise the a novelWriter project.""" + inPath = Path(path).resolve() + + # Initialise Storage Instance + # =========================== + + # Check what we're opening. Only three options are allowed: + # 1. A folder with a nwProject.nwx file in it (not home) + # 2. A full path to an nwProject.nwx file + if inPath.is_dir() and inPath != Path.home().resolve(): + nwxFile = inPath / nwFiles.PROJ_FILE + elif inPath.is_file() and inPath.name == nwFiles.PROJ_FILE: + nwxFile = inPath + else: + logger.error("Not a novelWriter project") + return NWStorageOpen.UNKOWN + + if not nwxFile.exists(): + # The .nwx file must exist to continue + logger.error("Not found: %s", nwxFile) + return NWStorageOpen.NOT_FOUND + + nwxPath = nwxFile.parent + + self._storagePath = nwxPath + self._runtimePath = nwxPath + self._lockFilePath = nwxPath / nwFiles.PROJ_LOCK + self._openMode = self.MODE_INPLACE + + # Check Project Lock + # ================== + + if clearLock: + self._clearLockFile() + + self._readLockFile() + if self._lockedBy and len(self._lockedBy) == 4: + if self._lockedBy[0] == "ERROR": + logger.warning("Failed to check lock file") + else: + logger.error("Project is locked, so not opening") + return NWStorageOpen.LOCKED + else: + logger.debug("Project is not locked") + + # Prepare Folder + # ============== + + basePath = self._runtimePath + metaPath = basePath / "meta" + contPath = basePath / "content" + try: + metaPath.mkdir(exist_ok=True) + contPath.mkdir(exist_ok=True) + except Exception as exc: + logger.error("Failed to create project folders", exc_info=exc) + self.clear() + return NWStorageOpen.FAILED + + # Check for legacy data folders + legacy = _LegacyStorage(self._project) + legacy.deprecatedFiles(basePath) + for child in basePath.iterdir(): + if child.is_dir() and child.name.startswith("data_"): + legacy.legacyDataFolder(basePath, child) + + self._writeLockFile() + self._ready = True + + return NWStorageOpen.READY def runPostSaveTasks(self, autoSave: bool = False) -> bool: # pragma: no cover """Run tasks after the project has been saved. @@ -147,7 +246,7 @@ class NWStorage: def closeSession(self) -> None: """Run tasks related to closing the session.""" - self.clearLockFile() + self._clearLockFile() self.clear() return @@ -157,26 +256,25 @@ class NWStorage: def getXmlReader(self) -> ProjectXMLReader | None: """Return a properly configured ProjectXMLReader instance.""" - if isinstance(self._runtimePath, Path): - projFile = self._runtimePath / nwFiles.PROJ_FILE - return ProjectXMLReader(projFile) + if isinstance(self._runtimePath, Path) and self._ready: + return ProjectXMLReader(self._runtimePath / nwFiles.PROJ_FILE) return None def getXmlWriter(self) -> ProjectXMLWriter | None: """Return a properly configured ProjectXMLWriter instance.""" - if isinstance(self._runtimePath, Path): - return ProjectXMLWriter(self._runtimePath) + if isinstance(self._runtimePath, Path) and self._ready: + return ProjectXMLWriter(self._runtimePath / nwFiles.PROJ_FILE) return None def getDocument(self, tHandle: str | None) -> NWDocument: """Return a document wrapper object.""" - if isinstance(self._runtimePath, Path): + if isinstance(self._runtimePath, Path) and self._ready: return NWDocument(self._project, tHandle) return NWDocument(self._project, None) def getMetaFile(self, fileName: str) -> Path | None: """Return the path to a file in the project meta folder.""" - if isinstance(self._runtimePath, Path): + if isinstance(self._runtimePath, Path) and self._ready: return self._runtimePath / "meta" / fileName return None @@ -190,59 +288,6 @@ class NWStorage: if item.suffix == ".nwd" and isHandle(item.stem) ] if contentPath else [] - def readLockFile(self) -> list[str]: - """Read the project lock file.""" - if self._lockFilePath is None: - return ["ERROR"] - - if not self._lockFilePath.exists(): - return [] - - try: - lines = self._lockFilePath.read_text(encoding="utf-8").strip().split(";") - except Exception: - logger.error("Failed to read project lockfile") - logException() - return ["ERROR"] - - if len(lines) != 4: - return ["ERROR"] - - return lines - - def writeLockFile(self) -> bool: - """Write the project lock file.""" - if self._lockFilePath is None: - return False - - data = [ - CONFIG.hostName, CONFIG.osType, - CONFIG.kernelVer, str(int(time())) - ] - try: - self._lockFilePath.write_text(";".join(data), encoding="utf-8") - except Exception: - logger.error("Failed to write project lockfile") - logException() - return False - - return True - - def clearLockFile(self) -> bool: - """Remove the lock file, if it exists.""" - if self._lockFilePath is None: - return False - - if self._lockFilePath.exists(): - try: - self._lockFilePath.unlink() - except Exception: - logger.error("Failed to remove project lockfile") - logException() - return False - - return True - def zipIt(self, target: str | Path, compression: int | None = None) -> bool: """Zip the content of the project at its runtime location into a zip file. This process will only grab files that are supposed to @@ -279,7 +324,7 @@ class NWStorage: zipObj.write(srcPath, zipPath) logger.debug("Added: %s", zipPath) except Exception: - logger.error("Failed to create acrhive") + logger.error("Failed to create archive") logException() return False @@ -289,59 +334,48 @@ class NWStorage: # Internal Functions ## - def _prepareStorage(self, checkLegacy: bool = True, newProject: bool = False) -> bool: - """Prepare the storage area for the project.""" - path = self._runtimePath - if not isinstance(path, Path): - logger.error("No path set") - self.clear() + def _readLockFile(self) -> None: + """Read the project lock file.""" + self._lockedBy = None + path = self._lockFilePath + if isinstance(path, Path) and path.exists(): + try: + self._lockedBy = path.read_text(encoding="utf-8").strip().split(";") + except Exception: + logger.error("Failed to read project lockfile") + logException() + self._lockedBy = ["ERROR", "ERROR", "ERROR", "ERROR"] + return + return + + def _writeLockFile(self) -> bool: + """Write the project lock file.""" + if self._lockFilePath is None: 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 - - if newProject: - # If it's a new project, we check that there is no existing - # project in the selected path. - if path.exists() and len(list(path.iterdir())) > 0: - logger.error("The new project folder is not empty") - 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 / "meta").mkdir(exist_ok=True) - except Exception as exc: - logger.error("Failed to create required project folders", exc_info=exc) - self.clear() + self._lockFilePath.write_text( + f"{CONFIG.hostName};{CONFIG.osType};{CONFIG.kernelVer};{int(time())}", + encoding="utf-8" + ) + except Exception: + logger.error("Failed to write project lockfile") + logException() 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 remaining checks. - return True - - legacy = _LegacyStorage(self._project) - - # Check for legacy data folders - for child in path.iterdir(): - if child.is_dir() and child.name.startswith("data_"): - legacy.legacyDataFolder(path, child) - - # Check for no longer used files, and delete them - legacy.deprecatedFiles(path) - return True - ## - # Legacy Project Data Handlers - ## + def _clearLockFile(self) -> bool: + """Remove the lock file, if it exists.""" + if self._lockFilePath is None: + return False + if self._lockFilePath.exists(): + try: + self._lockFilePath.unlink() + except Exception: + logger.error("Failed to remove project lockfile") + logException() + return False + self._lockedBy = None + return True # END Class NWStorage @@ -350,7 +384,7 @@ class _LegacyStorage: """Core: Legacy Storage Converter Utils A class with various functions to convert old file formats and - file/folder layout to the current project format. + file/folder layouts to the current project format. """ def __init__(self, project: NWProject) -> None: @@ -359,7 +393,8 @@ class _LegacyStorage: def legacyDataFolder(self, path: Path, child: Path) -> None: """Handle the content of a legacy data folder from a version 1.0 - project. + project. This format had 16 data folders where there now is only + one content folder. """ logger.info("Processing legacy data folder: %s", path) @@ -411,6 +446,7 @@ class _LegacyStorage: path / "meta" / nwFiles.OPTS_FILE ) + # Delete removed files remove = [ path / "meta" / "tagsIndex.json", # Renamed in 2.1 Beta 1 path / "meta" / "mainOptions.json", # Replaced in 0.5 @@ -457,7 +493,6 @@ class _LegacyStorage: # Save dictionary and clean up old file userDict.save() - assert wordJson.exists() wordList.unlink() except Exception: @@ -467,9 +502,7 @@ class _LegacyStorage: return def _convertOldLogFile(self, sessLog: Path, sessJson: Path) -> None: - """Convert the old text log file format to the new JSON Lines - format. - """ + """Convert the old text log file format to JSON Lines.""" if sessJson.exists() or not sessLog.exists(): # If the new file already exists, we won't overwrite it return @@ -508,7 +541,7 @@ class _LegacyStorage: return def _convertOldOptionsFile(self, optsOld: Path, optsNew: Path) -> None: - """Convert the old options state file format to the format.""" + """Convert the old options state file format to new format.""" if optsNew.exists() or not optsOld.exists(): # If the new file already exists, we won't overwrite it return