Add storage code to handle projecty structure and old projects
This commit is contained in:
+4
-4
@@ -3707,8 +3707,8 @@ helpful feedback and issue reports for the new features added in this, and previ
|
|||||||
|
|
||||||
**User Interface**
|
**User Interface**
|
||||||
|
|
||||||
* Added a preferences dialog for the program settings. No longer necessary to edit the config file.
|
* Added a preferences dialog for the program settings. It is no longer necessary to edit the config
|
||||||
PR #30.
|
file. PR #30.
|
||||||
* The document viewer remembers scroll bar position when pressing `Ctrl+R` on a document already
|
* The document viewer remembers scroll bar position when pressing `Ctrl+R` on a document already
|
||||||
being viewed. PR #28.
|
being viewed. PR #28.
|
||||||
* Removed version number from windows title. 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**
|
**Status Bar**
|
||||||
|
|
||||||
* Redesign of the status bar adding project and session stats as well as a session timer. PR #21.
|
* 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
|
* Project word count is written to the project file, which is needed for the session word count.
|
||||||
#21.
|
PR #21.
|
||||||
* Closing a project now clears the status bar. PR #21.
|
* Closing a project now clears the status bar. PR #21.
|
||||||
|
|
||||||
**Editor**
|
**Editor**
|
||||||
|
|||||||
+10
-41
@@ -264,42 +264,26 @@ class NWProject(QObject):
|
|||||||
|
|
||||||
return
|
return
|
||||||
|
|
||||||
def openProject(self, fileName, overrideLock=False):
|
def openProject(self, projPath, overrideLock=False):
|
||||||
"""Open the project file provided. If it doesn't exist, assume
|
"""Open the project file provided. If it doesn't exist, assume
|
||||||
it is a folder and look for the file within it. If successful,
|
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
|
parse the XML of the file and populate the project variables and
|
||||||
build the tree of project items.
|
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.clearProject()
|
||||||
self.projPath = os.path.abspath(os.path.dirname(fileName))
|
if not self._storage.openProjectInPlace(projPath):
|
||||||
logger.info("Opening project: %s", self.projPath)
|
|
||||||
|
|
||||||
# Standard Folders and Files
|
|
||||||
# ==========================
|
|
||||||
|
|
||||||
if not self.ensureFolderStructure():
|
|
||||||
self.clearProject()
|
|
||||||
return False
|
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)
|
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
|
# Project Lock
|
||||||
# ============
|
# ============
|
||||||
|
|
||||||
@@ -321,10 +305,6 @@ class NWProject(QObject):
|
|||||||
# Open The Project XML File
|
# Open The Project XML File
|
||||||
# =========================
|
# =========================
|
||||||
|
|
||||||
if not self._storage.openProjectInPlace(self.projPath):
|
|
||||||
self.clearProject()
|
|
||||||
return False
|
|
||||||
|
|
||||||
xmlReader = self._storage.getXmlReader()
|
xmlReader = self._storage.getXmlReader()
|
||||||
if not isinstance(xmlReader, ProjectXMLReader):
|
if not isinstance(xmlReader, ProjectXMLReader):
|
||||||
self.clearProject()
|
self.clearProject()
|
||||||
@@ -398,17 +378,6 @@ class NWProject(QObject):
|
|||||||
self._options.loadSettings()
|
self._options.loadSettings()
|
||||||
self._index.loadIndex()
|
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
|
# Clean up no longer used files
|
||||||
self._deprecatedFiles()
|
self._deprecatedFiles()
|
||||||
|
|
||||||
|
|||||||
+140
-7
@@ -57,6 +57,32 @@ class NWStorage:
|
|||||||
self._openMode = self.MODE_INACTIVE
|
self._openMode = self.MODE_INACTIVE
|
||||||
return
|
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
|
# Core Methods
|
||||||
##
|
##
|
||||||
@@ -70,19 +96,20 @@ class NWStorage:
|
|||||||
"""Open a novelWriter project in-place. That is, it is opened
|
"""Open a novelWriter project in-place. That is, it is opened
|
||||||
directly from a project folder.
|
directly from a project folder.
|
||||||
"""
|
"""
|
||||||
inPath = Path(path)
|
inPath = Path(path).resolve()
|
||||||
if inPath.is_file():
|
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
|
inPath = inPath.parent
|
||||||
|
|
||||||
if not inPath.is_dir():
|
|
||||||
logger.error("No such folder: %s", inPath)
|
|
||||||
self.clear()
|
|
||||||
return False
|
|
||||||
|
|
||||||
self._storagePath = inPath
|
self._storagePath = inPath
|
||||||
self._runtimePath = inPath
|
self._runtimePath = inPath
|
||||||
self._openMode = self.MODE_INPLACE
|
self._openMode = self.MODE_INPLACE
|
||||||
|
|
||||||
|
if self._prepareStorage(checkLegacy=True) is False:
|
||||||
|
self.clear()
|
||||||
|
return False
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def openProjectArchive(self, path):
|
def openProjectArchive(self, path):
|
||||||
@@ -142,10 +169,116 @@ class NWStorage:
|
|||||||
def _zipIt(self, target):
|
def _zipIt(self, target):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def _reeadLockFile(self):
|
def _readLockFile(self):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def _writeLockFile(self):
|
def _writeLockFile(self):
|
||||||
pass
|
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
|
# END Class NWStorage
|
||||||
|
|||||||
@@ -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]
|
assert "This project was saved by a newer version" in mockGUI.lastQuestion[1]
|
||||||
mockGUI.askResponse = True
|
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()
|
assert theProject.closeProject()
|
||||||
|
|
||||||
# END Test testCoreProject_Open
|
# END Test testCoreProject_Open
|
||||||
|
|||||||
Reference in New Issue
Block a user