Rewrite project open and creation in storage class

This commit is contained in:
Veronica Berglyd Olsen
2023-11-30 22:29:54 +01:00
parent 60b0a4c901
commit 4f42407c23
4 changed files with 213 additions and 178 deletions
+1 -1
View File
@@ -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")
+38 -34
View File
@@ -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))
+5 -7
View File
@@ -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
+169 -136
View File
@@ -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