diff --git a/novelwriter/core/buildsettings.py b/novelwriter/core/buildsettings.py index 524a1714..a5c5cd56 100644 --- a/novelwriter/core/buildsettings.py +++ b/novelwriter/core/buildsettings.py @@ -353,8 +353,6 @@ class BuildSettings: for item in project.tree: tHandle = item.itemHandle - if tHandle is None: - continue if item.isInactiveClass() or (item.itemRoot in self._skipRoot): result[tHandle] = (False, FilterMode.SKIPPED) continue diff --git a/novelwriter/core/coretools.py b/novelwriter/core/coretools.py index 03dda141..e7791613 100644 --- a/novelwriter/core/coretools.py +++ b/novelwriter/core/coretools.py @@ -287,7 +287,7 @@ class DocDuplicator: hMap: dict[str, str | None] = {t: None for t in items} for tHandle in items: newItem = self._project.tree.duplicate(tHandle) - if newItem is None or newItem.itemHandle is None: + if newItem is None: return hMap[tHandle] = newItem.itemHandle if newItem.itemParent in hMap: diff --git a/novelwriter/core/docbuild.py b/novelwriter/core/docbuild.py index e960debc..c3301e0f 100644 --- a/novelwriter/core/docbuild.py +++ b/novelwriter/core/docbuild.py @@ -101,8 +101,6 @@ class NWBuildDocument: self._queue = [] filtered = self._build.buildItemFilter(self._project) for item in self._project.tree: - if not item.itemHandle: - continue if filtered.get(item.itemHandle, False): self._queue.append(item.itemHandle) return diff --git a/novelwriter/core/document.py b/novelwriter/core/document.py index 8e4660a8..a8a99f8b 100644 --- a/novelwriter/core/document.py +++ b/novelwriter/core/document.py @@ -1,7 +1,6 @@ """ novelWriter – Project Document ============================== -Data class for a single novelWriter document File History: Created: 2018-09-29 [0.0.1] @@ -41,8 +40,15 @@ logger = logging.getLogger(__name__) class NWDocument: + """Core: Document Class - def __init__(self, project: NWProject, tHandle: str) -> None: + A Class wrapping a single novelWriter document file. It represents + a project item of nwItemType FILE. The file is not guaranteed to + exist, even if the item does. In the case it doesn't exist, reading + it returns a None rather than an empty or non-empty string. + """ + + def __init__(self, project: NWProject, tHandle: str | None) -> None: self._project = project @@ -243,7 +249,7 @@ class NWDocument: """Return a pointer to the currently open NWItem.""" return self._theItem - def getMeta(self) -> tuple[str, str | None, str | None, str | None]: + def getMeta(self) -> tuple[str, str | None, nwItemClass | None, nwItemLayout | None]: """Parse the document meta tag and return the name, parent, class and layout meta values. """ diff --git a/novelwriter/core/index.py b/novelwriter/core/index.py index 7f65a5e0..5aa6b7fe 100644 --- a/novelwriter/core/index.py +++ b/novelwriter/core/index.py @@ -76,7 +76,7 @@ class NWIndex: a rebuild of the index data. """ - def __init__(self, project): + def __init__(self, project: NWProject): self._project = project @@ -197,7 +197,7 @@ class NWIndex: logger.debug("Checking index") # Check that all files are indexed - for fHandle in self._project.projFiles: + for fHandle in self._project.storage.scanContent(): if fHandle not in self._itemIndex: logger.warning("Item '%s' is not in the index", fHandle) self.reIndexHandle(fHandle) diff --git a/novelwriter/core/item.py b/novelwriter/core/item.py index 21383d18..943e7c7b 100644 --- a/novelwriter/core/item.py +++ b/novelwriter/core/item.py @@ -1,7 +1,6 @@ """ novelWriter – Project Item Class ================================ -Data class for a project tree item File History: Created: 2018-10-27 [0.0.1] @@ -43,6 +42,13 @@ logger = logging.getLogger(__name__) class NWItem: + """Core: Item Data Class + + This class holds all the project information about a project item. + Each item must be associated with a project and have a valid handle. + Only the NWTree class should create instances of this class, and + must ensure that the handle is valid for all items in the tree. + """ __slots__ = ( "_project", "_name", "_handle", "_parent", "_root", "_order", @@ -51,11 +57,11 @@ class NWItem: "_paraCount", "_cursorPos", "_initCount", ) - def __init__(self, project: NWProject) -> None: + def __init__(self, project: NWProject, handle: str) -> None: self._project = project self._name = "" - self._handle = None + self._handle = handle self._parent = None self._root = None self._order = 0 @@ -81,31 +87,12 @@ class NWItem: return f"" def __bool__(self) -> bool: - """Evaluate to False if itemHandle is not set.""" - return self._handle is not None - - def __copy__(self) -> NWItem: - """Make a shallow copy of the current item.""" - item = NWItem(self._project) - item._name = self._name - item._handle = self._handle - item._parent = self._parent - item._root = self._root - item._order = self._order - item._type = self._type - item._class = self._class - item._layout = self._layout - item._status = self._status - item._import = self._import - item._active = self._active - item._expanded = self._expanded - item._heading = self._heading - item._charCount = self._charCount - item._wordCount = self._wordCount - item._paraCount = self._paraCount - item._cursorPos = self._cursorPos - item._initCount = self._initCount - return item + """The truthiness of the class. The handle used to be initiated + to None, but this is no longer the case. It should always + evaluate to True since 2.1-beta1, although unpack and the NWTree + class can leave it as an empty string. + """ + return bool(self._handle) ## # Properties @@ -116,7 +103,7 @@ class NWItem: return self._name @property - def itemHandle(self) -> str | None: + def itemHandle(self) -> str: return self._handle @property @@ -184,7 +171,7 @@ class NWItem: return self._cursorPos ## - # Pack/Unpack Data + # Pack/Unpack/Duplicate Data ## def pack(self) -> dict: @@ -227,8 +214,9 @@ class NWItem: meta = data.get("metaAttr", {}) name = data.get("nameAttr", {}) - if "handle" in item: - self.setHandle(item["handle"]) + handle = item.get("handle", "") + if isHandle(handle): + self._handle = handle else: logger.error("Item does not have a handle") return False @@ -269,6 +257,29 @@ class NWItem: return True + @classmethod + def duplicate(cls, source: NWItem, handle: str) -> NWItem: + """Make a copy of an item.""" + cls = NWItem(source._project, handle) + cls._name = source._name + cls._parent = source._parent + cls._root = source._root + cls._order = source._order + cls._type = source._type + cls._class = source._class + cls._layout = source._layout + cls._status = source._status + cls._import = source._import + cls._active = source._active + cls._expanded = source._expanded + cls._heading = source._heading + cls._charCount = source._charCount + cls._wordCount = source._wordCount + cls._paraCount = source._paraCount + cls._cursorPos = source._cursorPos + cls._initCount = source._initCount + return cls + ## # Lookup Methods ## @@ -387,14 +398,6 @@ class NWItem: self._name = "" return - def setHandle(self, handle: Any) -> None: - """Set the item handle, and ensure it is valid.""" - if isHandle(handle): - self._handle = handle - else: - self._handle = None - return - def setParent(self, handle: Any) -> None: """Set the parent handle, and ensure it is valid.""" if handle is None: diff --git a/novelwriter/core/options.py b/novelwriter/core/options.py index 9409f743..0cc7ac0a 100644 --- a/novelwriter/core/options.py +++ b/novelwriter/core/options.py @@ -77,7 +77,7 @@ class OptionState: the Config instead. """ - def __init__(self, project: NWProject): + def __init__(self, project: NWProject) -> None: self._project = project self._state = {} return @@ -87,8 +87,7 @@ class OptionState: ## def loadSettings(self) -> bool: - """Load the options dictionary from the project settings file. - """ + """Load the options dictionary from the project.""" stateFile = self._project.storage.getMetaFile(nwFiles.OPTS_FILE) if not isinstance(stateFile, Path): return False @@ -116,7 +115,7 @@ class OptionState: return True def saveSettings(self) -> bool: - """Save the options dictionary to the project settings file.""" + """Save the options dictionary to the project.""" stateFile = self._project.storage.getMetaFile(nwFiles.OPTS_FILE) if not isinstance(stateFile, Path): return False diff --git a/novelwriter/core/project.py b/novelwriter/core/project.py index 37b46423..51cc8955 100644 --- a/novelwriter/core/project.py +++ b/novelwriter/core/project.py @@ -28,6 +28,7 @@ import json import logging from time import time +from typing import TYPE_CHECKING, Iterator from pathlib import Path from functools import partial @@ -38,7 +39,6 @@ from novelwriter.enum import nwItemType, nwItemClass, nwItemLayout, nwAlert from novelwriter.error import logException from novelwriter.constants import trConst, nwLabels from novelwriter.core.tree import NWTree -from novelwriter.core.item import NWItem from novelwriter.core.index import NWIndex from novelwriter.core.options import OptionState from novelwriter.core.storage import NWStorage @@ -46,9 +46,14 @@ from novelwriter.core.sessions import NWSessionLog from novelwriter.core.projectxml import ProjectXMLReader, ProjectXMLWriter, XMLReadState from novelwriter.core.projectdata import NWProjectData from novelwriter.common import ( - checkStringNone, formatTimeStamp, hexToInt, isHandle, makeFileNameSafe, minmax + checkStringNone, formatTimeStamp, hexToInt, makeFileNameSafe, minmax ) +if TYPE_CHECKING: # pragma: no cover + from novelwriter.guimain import GuiMain + from novelwriter.core.item import NWItem + from novelwriter.core.status import NWStatus + logger = logging.getLogger(__name__) @@ -56,7 +61,7 @@ class NWProject(QObject): projectStatusChanged = pyqtSignal(bool) - def __init__(self, mainGui): + def __init__(self, mainGui: GuiMain) -> None: super().__init__(parent=mainGui) # Internal @@ -70,20 +75,14 @@ class NWProject(QObject): self._index = NWIndex(self) # The project index self._session = NWSessionLog(self) # The session record - # Data Cache - self._langData = {} # Localisation data - # Project Status + self._langData = {} # Localisation data self._projChanged = False # The project has unsaved changes self._lockedBy = None # Data on which computer has the project open - self._projFiles = [] # A list of all files in the content folder on load # Internal Mapping self.tr = partial(QCoreApplication.translate, "NWProject") - # Set Defaults - self.clearProject() - return ## @@ -91,23 +90,23 @@ class NWProject(QObject): ## @property - def options(self): + def options(self) -> OptionState: return self._options @property - def storage(self): + def storage(self) -> NWStorage: return self._storage @property - def data(self): + def data(self) -> NWProjectData: return self._data @property - def tree(self): + def tree(self) -> NWTree: return self._tree @property - def index(self): + def index(self) -> NWIndex: return self._index @property @@ -115,59 +114,33 @@ class NWProject(QObject): return self._session @property - def projOpened(self): + def projOpened(self) -> float: return self._session.start @property - def projChanged(self): + def projChanged(self) -> bool: return self._projChanged - @property - def projFiles(self): - return self._projFiles - ## # Item Methods ## - def newRoot(self, itemClass, label=None): - """Add a new root item. If label is None, use the class label. + def newRoot(self, itemClass: nwItemClass, label: str | None = None) -> str: + """Add a new root folder to the project. If label is not set, + use the class label. """ - if label is None: - label = trConst(nwLabels.CLASS_NAME[itemClass]) - newItem = NWItem(self) - newItem.setName(label) - newItem.setType(nwItemType.ROOT) - newItem.setClass(itemClass) - self._tree.append(None, None, newItem) - self._tree.updateItemData(newItem.itemHandle) - return newItem.itemHandle + label = label or trConst(nwLabels.CLASS_NAME[itemClass]) + return self._tree.create(label, None, nwItemType.ROOT, itemClass) - def newFolder(self, label, pHandle): - """Add a new folder with a given label and parent item. - """ - if pHandle not in self._tree: - return None - newItem = NWItem(self) - newItem.setName(label) - newItem.setType(nwItemType.FOLDER) - self._tree.append(None, pHandle, newItem) - self._tree.updateItemData(newItem.itemHandle) - return newItem.itemHandle + def newFolder(self, label: str, parent: str) -> str | None: + """Add a new folder with a given label and parent item.""" + return self._tree.create(label, parent, nwItemType.FOLDER) - def newFile(self, label, pHandle): - """Add a new file with a given label and parent item. - """ - if pHandle not in self._tree: - return None - newItem = NWItem(self) - newItem.setName(label) - newItem.setType(nwItemType.FILE) - self._tree.append(None, pHandle, newItem) - self._tree.updateItemData(newItem.itemHandle) - return newItem.itemHandle + def newFile(self, label: str, parent: str) -> str | None: + """Add a new file with a given label and parent item.""" + return self._tree.create(label, parent, nwItemType.FILE) - def writeNewFile(self, tHandle, hLevel, isDocument, addText=""): + def writeNewFile(self, tHandle: str, hLevel: int, isDocument: bool, addText: str = "") -> bool: """Write content to a new document after it is created. This will not run if the file exists and is not empty. """ @@ -193,7 +166,7 @@ class NWProject(QObject): return True - def removeItem(self, tHandle): + def removeItem(self, tHandle: str) -> bool: """Remove an item from the project. This will delete both the project entry and a document file if it exists. """ @@ -210,45 +183,40 @@ class NWProject(QObject): return True - def trashFolder(self): - """Add the special trash root folder to the project. - """ + def trashFolder(self) -> str: + """Add the special trash root folder to the project.""" trashHandle = self._tree.trashRoot() if trashHandle is None: - newItem = NWItem(self) - newItem.setName(trConst(nwLabels.CLASS_NAME[nwItemClass.TRASH])) - newItem.setType(nwItemType.ROOT) - newItem.setClass(nwItemClass.TRASH) - self._tree.append(None, None, newItem) - self._tree.updateItemData(newItem.itemHandle) - return newItem.itemHandle - + label = trConst(nwLabels.CLASS_NAME[nwItemClass.TRASH]) + return self._tree.create(label, None, nwItemType.ROOT, nwItemClass.TRASH) return trashHandle ## # Project Methods ## - def clearProject(self): + def clearProject(self) -> None: """Clear the data for the current project, and set them to default values. - """ - # Project Status - self._projChanged = False - # Project Tree + 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._data = NWProjectData(self) self._session = NWSessionLog(self) - # Project Settings - self._projFiles = [] + # Project Status + self._langData = {} + self._projChanged = False return - def openProject(self, projPath, overrideLock=False): + def openProject(self, projPath: str | Path, overrideLock: bool = False) -> 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 @@ -360,15 +328,13 @@ class NWProject(QObject): ) # Check the project tree consistency - for tItem in self._tree: - if tItem: - tHandle = tItem.itemHandle - logger.debug("Checking item '%s'", tHandle) - if not self._tree.updateItemData(tHandle): - logger.error("There was a problem the item, and it has been removed") - del self._tree[tHandle] # The file will be re-added as orphaned + # This also handles any orphaned files found + orphans, recovered = self._tree.checkConsistency(self.tr("Recovered")) + if orphans > 0: + self.mainGui.makeAlert(self.tr( + "Found {0} orphaned file(s) in the project. {1} file(s) were recovered." + ).format(orphans, recovered), nwAlert.WARN) - self._scanProjectFolder() self._index.loadIndex() if xmlReader.state == XMLReadState.WAS_LEGACY: # Often, the index needs to be rebuilt when updating format @@ -382,7 +348,7 @@ class NWProject(QObject): return True - def saveProject(self, autoSave=False): + def saveProject(self, autoSave: bool = False) -> bool: """Save the project main XML file. The saving command itself uses a temporary filename, and the file is replaced afterwards to make sure if the save fails, we're not left with a truncated @@ -435,22 +401,19 @@ class NWProject(QObject): return True - def closeProject(self, idleTime=0.0): - """Close the current project and clear all meta data. - """ + def closeProject(self, idleTime: float = 0.0) -> None: + """Close the current project and clear all meta data.""" logger.info("Closing project") self._options.saveSettings() self._tree.writeToCFile() self._session.appendSession(idleTime) - self._storage.clearLockFile() self._storage.closeSession() self.clearProject() self._lockedBy = None - return True + return - def backupProject(self, doNotify): - """Create a zip file of the entire project. - """ + def backupProject(self, doNotify: bool) -> bool: + """Create a zip file of the entire project.""" if not self._storage.isOpen(): logger.error("No project open") return False @@ -507,9 +470,8 @@ class NWProject(QObject): # Setters ## - def setDefaultStatusImport(self): - """Set the default status and importance values. - """ + def setDefaultStatusImport(self) -> None: + """Set the default status and importance values.""" self._data.itemStatus.write(None, self.tr("New"), (100, 100, 100)) self._data.itemStatus.write(None, self.tr("Note"), (200, 50, 0)) self._data.itemStatus.write(None, self.tr("Draft"), (200, 150, 0)) @@ -520,43 +482,40 @@ class NWProject(QObject): self._data.itemImport.write(None, self.tr("Main"), (50, 200, 0)) return - def setProjectLang(self, theLang): - """Set the project-specific language. - """ - theLang = checkStringNone(theLang, None) - if self._data.language != theLang: - self._data.setLanguage(theLang) + def setProjectLang(self, language: str | None) -> None: + """Set the project-specific language.""" + language = checkStringNone(language, None) + if self._data.language != language: + self._data.setLanguage(language) self._loadProjectLocalisation() self.setProjectChanged(True) - return True + return - def setTreeOrder(self, newOrder): + def setTreeOrder(self, order: list[str]) -> None: """A list representing the linear/flattened order of project items in the GUI project tree. The user can rearrange the order by drag-and-drop. Forwarded to the NWTree class. """ - if len(self._tree) != len(newOrder): + if len(self._tree) != len(order): logger.warning("Sizes of new and old tree order do not match") - self._tree.setOrder(newOrder) + self._tree.setOrder(order) self.setProjectChanged(True) - return True + return - def setStatusColours(self, newCols, delCols): - """Update the list of novel file status flags. - """ - return self._setStatusImport(newCols, delCols, self._data.itemStatus) + def setStatusColours(self, new: list[dict], deleted: list[str]) -> bool: + """Update the list of novel file status flags.""" + return self._setStatusImport(new, deleted, self._data.itemStatus) - def setImportColours(self, newCols, delCols): - """Update the list of note file importance flags. - """ - return self._setStatusImport(newCols, delCols, self._data.itemImport) + def setImportColours(self, new: list[dict], deleted: list[str]) -> bool: + """Update the list of note file importance flags.""" + return self._setStatusImport(new, deleted, self._data.itemImport) - def setProjectChanged(self, value): + def setProjectChanged(self, status: bool) -> bool: """Toggle the project changed flag, and propagate the information to the GUI statusbar. """ - if isinstance(value, bool): - self._projChanged = value + if isinstance(status, bool): + self._projChanged = status self.projectStatusChanged.emit(self._projChanged) return self._projChanged @@ -576,7 +535,7 @@ class NWProject(QObject): """ return self._data.editTime + round(time() - self._session.start) - def getProjectItems(self): + def getProjectItems(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, @@ -617,19 +576,19 @@ class NWProject(QObject): logger.error("Item '%s' has no parent in current tree", tHandle) tItem.setParent(None) yield tItem + return ## # Class Methods ## - def updateWordCounts(self): - """Update the total word count values. - """ + def updateWordCounts(self) -> None: + """Update the total word count values.""" novel, notes = self._tree.sumWords() self._data.setCurrCounts(novel=novel, notes=notes) return - def countStatus(self): + def countStatus(self) -> None: """Count how many times the various status flags are used in the project tree. The counts themselves are kept in the NWStatus objects. This is essentially a refresh. @@ -643,18 +602,18 @@ class NWProject(QObject): self._data.itemImport.increment(nwItem.itemImport) return - def localLookup(self, theWord): - """Look up a word in the translation map for the project and - return it. The variable is cast to a string before lookup. If - the word does not exist, it returns itself. + def localLookup(self, word: str | int) -> str: + """Look up a word or number in the translation map for the + project and return it. The variable is cast to a string before + lookup. If the word does not exist, it returns itself. """ - return self._langData.get(str(theWord), str(theWord)) + return self._langData.get(str(word), str(word)) ## # Internal Functions ## - def _setStatusImport(self, new, delete, target): + def _setStatusImport(self, new: list[dict], delete: list[str], target: NWStatus) -> bool: """Update the list of novel file status or importance flags, and delete those that have been requested deleted. """ @@ -676,9 +635,8 @@ class NWProject(QObject): return True - def _loadProjectLocalisation(self): - """Load the language data for the current project language. - """ + def _loadProjectLocalisation(self) -> bool: + """Load the language data for the current project language.""" if self._data.language is None or CONFIG._nwLangPath is None: self._langData = {} return False @@ -691,7 +649,6 @@ class NWProject(QObject): with open(langFile, mode="r", encoding="utf-8") as inFile: self._langData = json.load(inFile) logger.debug("Loaded project language file: %s", langFile.name) - except Exception: logger.error("Failed to project language file") logException() @@ -699,106 +656,4 @@ class NWProject(QObject): return True - def _scanProjectFolder(self): - """Scan the project folder and check that the files in it are - also in the project XML file. If they aren't, import them as - orphaned files so the user can either delete them, or put them - back into the project tree. - """ - contentPath = self._storage.contentPath - if not isinstance(contentPath, Path): - return False - - # Then check the files in the data folder - logger.debug("Checking files in project content folder") - orphanFiles = [] - self._projFiles = [] - - for item in contentPath.iterdir(): - itemName = item.name - if not itemName.endswith(".nwd"): - logger.warning("Skipping file: %s", itemName) - continue - if len(itemName) != 17: - logger.warning("Skipping file: %s", itemName) - continue - - fHandle = itemName[:13] - if not isHandle(fHandle): - logger.warning("Skipping file: %s", itemName) - continue - - if fHandle in self._tree: - self._projFiles.append(fHandle) - logger.debug("Checking file %s, handle '%s': OK", itemName, fHandle) - else: - logger.warning("Checking file %s, handle '%s': Orphaned", itemName, fHandle) - orphanFiles.append(fHandle) - - # Report status - if len(orphanFiles) > 0: - self.mainGui.makeAlert(self.tr( - "Found {0} orphaned file(s) in project folder." - ).format(len(orphanFiles)), nwAlert.WARN) - else: - logger.debug("File check OK") - return - - # Handle orphans - nOrph = 0 - noWhere = False - oPrefix = self.tr("Recovered") - for oHandle in orphanFiles: - - # Look for meta data - oName = "" - oParent = None - oClass = None - oLayout = None - - aDoc = self._storage.getDocument(oHandle) - if aDoc.readDocument(isOrphan=True) is not None: - oName, oParent, oClass, oLayout = aDoc.getMeta() - - if oName: - oName = self.tr("[{0}] {1}").format( - oPrefix, oName.replace("[%s]" % oPrefix, "").strip() - ) - else: - nOrph += 1 - oName = self.tr("Recovered File {0}").format(nOrph) - - # Recover file meta data - if oClass is None: - oClass = nwItemClass.NOVEL - - if oLayout is None: - oLayout = nwItemLayout.NOTE - - if oParent is None or oParent not in self._tree: - oParent = self._tree.findRoot(oClass) - if oParent is None: - oParent = self._tree.findRoot(nwItemClass.NOVEL) - - # If the file still has no parent item, skip it - if oParent is None: - noWhere = True - continue - - orphItem = NWItem(self) - orphItem.setName(oName) - orphItem.setType(nwItemType.FILE) - orphItem.setClass(oClass) - orphItem.setLayout(oLayout) - self._tree.append(oHandle, oParent, orphItem) - self._tree.updateItemData(orphItem.itemHandle) - - if noWhere: - self.mainGui.makeAlert(self.tr( - "One or more orphaned files could not be added back into the project. " - "Make sure at least a Novel root folder exists." - ), nwAlert.WARN) - - return True - # END Class NWProject diff --git a/novelwriter/core/storage.py b/novelwriter/core/storage.py index 8677ad75..84e4ee35 100644 --- a/novelwriter/core/storage.py +++ b/novelwriter/core/storage.py @@ -33,7 +33,7 @@ from zipfile import ZIP_DEFLATED, ZIP_STORED, ZipFile from novelwriter import CONFIG from novelwriter.error import logException -from novelwriter.common import minmax +from novelwriter.common import isHandle, minmax from novelwriter.constants import nwFiles from novelwriter.core.document import NWDocument from novelwriter.core.projectxml import ProjectXMLReader, ProjectXMLWriter @@ -67,6 +67,7 @@ class NWStorage: """Reset internal variables.""" self._storagePath = None self._runtimePath = None + self._lockFilePath = None self._openMode = self.MODE_INACTIVE return @@ -146,7 +147,7 @@ class NWStorage: def closeSession(self): """Run tasks related to closing the session.""" - # Clear lockfile + self.clearLockFile() self.clear() return @@ -179,7 +180,17 @@ class NWStorage: return self._runtimePath / "meta" / fileName return None - def readLockFile(self) -> list: + def scanContent(self) -> list[str]: + """Scan the content folder and return the handle of all files + found in it. Files that do not match the pattern are ignored. + """ + contentPath = self.contentPath + return [ + item.stem for item in contentPath.iterdir() + 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"] @@ -188,7 +199,7 @@ class NWStorage: return [] try: - lines = self._lockFilePath.read_text(encoding="utf-8").split(";") + lines = self._lockFilePath.read_text(encoding="utf-8").strip().split(";") except Exception: logger.error("Failed to read project lockfile") logException() diff --git a/novelwriter/core/tree.py b/novelwriter/core/tree.py index b163869a..7da3e6ac 100644 --- a/novelwriter/core/tree.py +++ b/novelwriter/core/tree.py @@ -1,7 +1,6 @@ """ novelWriter – Project Tree Class ================================ -Data class for the project's tree of project items File History: Created: 2020-05-07 [0.4.5] @@ -24,16 +23,15 @@ along with this program. If not, see . """ from __future__ import annotations -import copy import random import logging -from typing import TYPE_CHECKING, Iterator +from typing import TYPE_CHECKING, Iterator, Literal, overload from pathlib import Path from novelwriter.enum import nwItemClass, nwItemLayout, nwItemType from novelwriter.error import logException -from novelwriter.common import checkHandle +from novelwriter.common import isHandle from novelwriter.constants import nwFiles from novelwriter.core.item import NWItem @@ -44,6 +42,22 @@ logger = logging.getLogger(__name__) class NWTree: + """Core: Project Tree Data Class + + Only one instance of this class should exist in the project class. + This class holds all the project items of the project as instances + of NWItem. + + For historical reasons, the order of the items is saved in a + separate list from the items themselves, which are stored in a + dictionary. This is somewhat redundant with the newer versions of + Python, but is still practical as it's easier to update the item + order as a list. + + Each item has a handle, which is a random hex string of length 13. + The handle is the name of the item everywhere in novelWriter, and is + also used for file names. + """ MAX_DEPTH = 1000 # Cap of tree traversing for loops @@ -52,7 +66,7 @@ class NWTree: self._project = project self._projTree: dict[str, NWItem] = {} # Holds all the items of the project - self._treeOrder: list[str] = [] # The order of the tree items on the tree view + self._treeOrder: list[str] = [] # The order of the tree items in the tree view self._treeRoots: dict[str, NWItem] = {} # The root items of the tree self._trashRoot = None # The handle of the trash root folder @@ -79,12 +93,42 @@ class NWTree: """Returns a copy of the list of all the active handles.""" return self._treeOrder.copy() - def append(self, tHandle: str | None, pHandle: str | None, nwItem: NWItem) -> bool: - """Add a new item to the end of the tree.""" - tHandle = checkHandle(tHandle, None, True) - pHandle = checkHandle(pHandle, None, True) - if tHandle is None: + @overload + def create(self, label: str, parent: None, itemType: Literal[nwItemType.ROOT], + itemClass: nwItemClass) -> str: # pragma: no cover + pass + + @overload + def create(self, label: str, parent: str | None, itemType: nwItemType, + itemClass: nwItemClass = nwItemClass.NO_CLASS) -> str | None: # pragma: no cover + pass + + def create(self, label, parent, itemType, itemClass=nwItemClass.NO_CLASS): + """Create a new item in the project tree, and return its handle. + If the item cannot be added to the project because of an invalid + parent, None is returned. For root elements, this cannot occur. + """ + parent = None if itemType == nwItemType.ROOT else parent + if parent is None or parent in self._treeOrder: tHandle = self._makeHandle() + newItem = NWItem(self._project, tHandle) + newItem.setName(label) + newItem.setParent(parent) + newItem.setType(itemType) + newItem.setClass(itemClass) + self.append(newItem) + self.updateItemData(tHandle) + return tHandle + return None + + def append(self, nwItem: NWItem) -> bool: + """Add a new item to the end of the tree.""" + tHandle = nwItem.itemHandle + pHandle = nwItem.itemParent + + if not isHandle(tHandle): + logger.warning("Invalid item handle '%s' detected, skipping", tHandle) + return False if tHandle in self._projTree: logger.warning("Duplicate handle '%s' detected, skipping", tHandle) @@ -92,9 +136,6 @@ class NWTree: logger.debug("Adding item '%s' with parent '%s'", str(tHandle), str(pHandle)) - nwItem.setHandle(tHandle) - nwItem.setParent(pHandle) - if nwItem.isRootType(): logger.debug("Item '%s' is a root item", str(tHandle)) self._treeRoots[tHandle] = nwItem @@ -119,8 +160,8 @@ class NWTree: """Duplicate an item and set a new handle.""" sItem = self.__getitem__(sHandle) if isinstance(sItem, NWItem): - nItem = copy.copy(sItem) - if self.append(None, sItem.itemParent, nItem): + nItem = NWItem.duplicate(sItem, self._makeHandle()) + if self.append(nItem): logger.info("Duplicated item '%s' -> '%s'", sHandle, nItem.itemHandle) return nItem return None @@ -142,12 +183,68 @@ class NWTree: """ self.clear() for item in data: - nwItem = NWItem(self._project) + nwItem = NWItem(self._project, "") # Handle is set by unpack() if nwItem.unpack(item): - self.append(nwItem.itemHandle, nwItem.itemParent, nwItem) + self.append(nwItem) nwItem.saveInitialCount() return + def checkConsistency(self, prefix: str) -> tuple[int, int]: + """Check the project tree consistency. Also check the content + folder and add back files that were discovered but were not + included in the tree. This function should only be called after + the project file has been processed, but before the loading of + the project returns. The functions requires a prefix string to + mark recovered files. + """ + storage = self._project.storage + files = set(storage.scanContent()) + for tHandle in self._treeOrder: + if self.updateItemData(tHandle): + logger.debug("Checking item '%s' ... OK", tHandle) + files.discard(tHandle) # Remove it from the record + else: + logger.error("Checking item '%s' ... ERROR", tHandle) + self.__delitem__(tHandle) # The file will be re-added as orphaned + + orphans = len(files) + if orphans == 0: + logger.info("Checked project files: OK") + return 0, 0 + + logger.warning("Found %d file(s) not tracked in project", orphans) + recovered = 0 + for cHandle in files: + aDoc = storage.getDocument(cHandle) + aDoc.readDocument(isOrphan=True) + oName, oParent, oClass, oLayout = aDoc.getMeta() + + oName = oName or cHandle + oParent = oParent if oParent in self._treeOrder else None + oClass = oClass or nwItemClass.NOVEL + oLayout = oLayout or nwItemLayout.NOTE + + # If the parent doesn't exists, find a new home + if oParent is None: # Add it to the first available class root + oParent = self.findRoot(oClass) + if oParent is None: # Otherwise, add to the Novel root + oParent = self.findRoot(nwItemClass.NOVEL) + if oParent is None: # If not, give up + continue + + # Create a new item + newItem = NWItem(self._project, cHandle) + newItem.setName(f"[{prefix}] {oName}") + newItem.setParent(oParent) + newItem.setType(nwItemType.FILE) + newItem.setClass(oClass) + newItem.setLayout(oLayout) + if self.append(newItem): + self.updateItemData(cHandle) + recovered += 1 + + return orphans, recovered + def writeToCFile(self) -> bool: """Write the convenience table of contents file in the root of the project directory. @@ -314,7 +411,7 @@ class NWTree: return self._trashRoot return None - def findRoot(self, itemClass: nwItemClass) -> str | None: + def findRoot(self, itemClass: nwItemClass | None) -> str | None: """Find the first root item for a given class.""" for aRoot in self._treeRoots: tItem = self.__getitem__(aRoot) diff --git a/novelwriter/gui/projtree.py b/novelwriter/gui/projtree.py index 8274d984..6a522a01 100644 --- a/novelwriter/gui/projtree.py +++ b/novelwriter/gui/projtree.py @@ -1046,13 +1046,12 @@ class GuiProjectTree(QTreeWidget): """ logger.debug("Building the project tree ...") self.clearTree() - - iCount = 0 + count = 0 for nwItem in self.theProject.getProjectItems(): - iCount += 1 + count += 1 self._addTreeItem(nwItem) - - logger.debug("%d item(s) added to the project tree", iCount) + if count > 0: + logger.info("%d item(s) added to the project tree", count) return def undoLastMove(self): diff --git a/tests/test_core/test_core_buildsettings.py b/tests/test_core/test_core_buildsettings.py index 14c19761..e809d6d1 100644 --- a/tests/test_core/test_core_buildsettings.py +++ b/tests/test_core/test_core_buildsettings.py @@ -31,7 +31,6 @@ from mocked import causeOSError from novelwriter.enum import nwBuildFmt, nwItemClass from novelwriter.constants import nwFiles -from novelwriter.core.item import NWItem from novelwriter.core.project import NWProject from novelwriter.core.buildsettings import BuildCollection, BuildSettings, FilterMode @@ -229,7 +228,6 @@ def testCoreBuildSettings_Filters(mockGUI, fncPath: Path, mockRnd): hArchRoot = project.newRoot(nwItemClass.ARCHIVE, "Archive") hPlotDoc = project.newFile("Main Plot", C.hPlotRoot) hCharDoc = project.newFile("Jane Doe", C.hCharRoot) - initLen = len(project.tree) # With no changes assert build.isRootAllowed(C.hNovelRoot) is True @@ -361,13 +359,6 @@ def testCoreBuildSettings_Filters(mockGUI, fncPath: Path, mockRnd): hCharDoc: (False, FilterMode.FILTERED), } - # Check error handling - project.tree._treeOrder.append("00000000000ff") - project.tree._projTree["00000000000ff"] = NWItem(project) - assert project.tree["00000000000ff"].itemHandle is None # type: ignore - filtered = build.buildItemFilter(project, withRoots=False) - assert len(filtered) == initLen - # No valid project provided assert build.buildItemFilter(None) == {} # type: ignore diff --git a/tests/test_core/test_core_docbuild.py b/tests/test_core/test_core_docbuild.py index bd242f4f..16e8941f 100644 --- a/tests/test_core/test_core_docbuild.py +++ b/tests/test_core/test_core_docbuild.py @@ -29,7 +29,6 @@ from tools import C, ODT_IGNORE, buildTestProject, cmpFiles from mocked import causeException, causeOSError from novelwriter.enum import nwBuildFmt -from novelwriter.core.item import NWItem from novelwriter.core.tomd import ToMarkdown from novelwriter.core.toodt import ToOdt from novelwriter.core.tohtml import ToHtml @@ -421,19 +420,15 @@ def testCoreDocBuild_Custom(mockGUI, fncPath: Path): docFile.unlink() # Add an invalid item to the project - bHandle = "0123456789abc" nHandle = "0123456789def" - project.tree._treeOrder.append(bHandle) - project.tree._projTree[bHandle] = NWItem(project) # Handle should be None project.tree._treeOrder.append(nHandle) - project.tree._projTree[nHandle] = None + project.tree._projTree[nHandle] = None # type: ignore docBuild.queueAll() assert len(docBuild) == 8 - docBuild.addDocument(bHandle) docBuild.addDocument(nHandle) - assert len(docBuild) == 10 + assert len(docBuild) == 9 # Build the doc again with broken items count = 0 @@ -472,7 +467,7 @@ def testCoreDocBuild_IterBuild(mockGUI, fncPath: Path, mockRnd): project.storage.getDocument(hCharDoc).writeDocument("# Jane Doe\n~~Text~~") # Fix project order as this has never been opened in a GUI - project.tree.setOrder([ + project.tree.setOrder([ # type: ignore C.hNovelRoot, C.hTitlePage, C.hChapterDir, C.hChapterDoc, C.hSceneDoc, C.hPlotRoot, hPlotDoc, C.hCharRoot, hCharDoc, C.hWorldRoot ]) diff --git a/tests/test_core/test_core_index.py b/tests/test_core/test_core_index.py index 9b8871d6..c3b09a43 100644 --- a/tests/test_core/test_core_index.py +++ b/tests/test_core/test_core_index.py @@ -147,7 +147,7 @@ def testCoreIndex_LoadSave(monkeypatch, prjLipsum, mockGUI, tstPaths): assert "7a992350f3eb6" in theIndex._itemIndex # Finalise - assert theProject.closeProject() is True + theProject.closeProject() # END Test testCoreIndex_LoadSave @@ -195,7 +195,7 @@ def testCoreIndex_ScanThis(mockGUI): assert theBits == ["@tag", "this", "and this"] assert thePos == [0, 6, 12] - assert theProject.closeProject() is True + theProject.closeProject() # END Test testCoreIndex_ScanThis @@ -273,7 +273,7 @@ def testCoreIndex_CheckThese(mockGUI, fncPath, mockRnd): assert theIndex.checkThese(["@who", "Jane", "John"], cItem) == [False, False, False] assert theIndex.checkThese(["@pov", "Jane", "John"], nItem) == [True, True, False] - assert theProject.closeProject() is True + theProject.closeProject() # END Test testCoreIndex_CheckThese @@ -494,7 +494,7 @@ def testCoreIndex_ScanText(mockGUI, fncPath, mockRnd): assert theIndex._itemIndex[pHandle]["T0000"].paraCount == 1 assert theIndex._itemIndex[pHandle]["T0000"].synopsis == "" - assert theProject.closeProject() is True + theProject.closeProject() # END Test testCoreIndex_ScanText @@ -774,7 +774,7 @@ def testCoreIndex_ExtractData(mockGUI, fncPath, mockRnd): assert theIndex.saveIndex() is True assert theProject.saveProject() is True - assert theProject.closeProject() is True + theProject.closeProject() # END Test testCoreIndex_ExtractData diff --git a/tests/test_core/test_core_item.py b/tests/test_core/test_core_item.py index d488de3c..752bfc10 100644 --- a/tests/test_core/test_core_item.py +++ b/tests/test_core/test_core_item.py @@ -37,7 +37,8 @@ def testCoreItem_Setters(mockGUI, mockRnd, fncPath): theProject = NWProject(mockGUI) mockRnd.reset() buildTestProject(theProject, fncPath) - theItem = NWItem(theProject) + theItem = NWItem(theProject, "0000000000000") + assert theItem.itemHandle == "0000000000000" statusKeys = ["s000000", "s000001", "s000002", "s000003"] importKeys = ["i000004", "i000005", "i000006", "i000007"] @@ -52,16 +53,6 @@ def testCoreItem_Setters(mockGUI, mockRnd, fncPath): theItem.setName(123) assert theItem.itemName == "" - # Handle - theItem.setHandle(123) - assert theItem.itemHandle is None - theItem.setHandle("0123456789abcdef") - assert theItem.itemHandle is None - theItem.setHandle("0123456789abg") - assert theItem.itemHandle is None - theItem.setHandle("0123456789abc") - assert theItem.itemHandle == "0123456789abc" - # Parent theItem.setParent(None) assert theItem.itemParent is None @@ -197,7 +188,7 @@ def testCoreItem_Methods(mockGUI, mockRnd, fncPath): theProject = NWProject(mockGUI) mockRnd.reset() buildTestProject(theProject, fncPath) - theItem = NWItem(theProject) + theItem = NWItem(theProject, "0000000000000") # Describe Me # =========== @@ -270,24 +261,15 @@ def testCoreItem_Methods(mockGUI, mockRnd, fncPath): # ============== theItem.setName("New Item") - theItem.setHandle("1234567890abc") - theItem.setParent("4567890abcdef") - assert repr(theItem) == "" + theItem.setParent("1111111111111") + assert repr(theItem) == "" # Truthiness # ========== - bItem = NWItem(theProject) - - # An item with a handle is valid - bItem.setHandle(theProject.tree._makeHandle()) - assert bool(bItem) is True - assert bItem - - # An item without a handle is invalid - bItem.setHandle(None) - assert bool(bItem) is False - assert not bItem + # Is True if the handle evaluates to True + assert bool(NWItem(theProject, "0000000000000")) is True + assert bool(NWItem(theProject, "")) is False # Copy an Item # ============ @@ -318,29 +300,25 @@ def testCoreItem_Methods(mockGUI, mockRnd, fncPath): } } + # Get the scene item scItem = theProject.tree[C.hSceneDoc] - cpItem = copy.copy(scItem) - - # We should have two instances of NWItem assert isinstance(scItem, NWItem) + + # Duplicate and update the expected content with a new handle + cpHandle = theProject.tree._makeHandle() + cpData = copy.deepcopy(scData) + cpData["itemAttr"]["handle"] = cpHandle + + # Duplicate the scene item + cpItem = NWItem.duplicate(scItem, cpHandle) assert isinstance(cpItem, NWItem) assert scItem is not cpItem # They should both point to the same project instance assert scItem._project is cpItem._project - # They should contain the same data + # They should contain the same data, except for the handle assert scItem.pack() == scData - assert cpItem.pack() == scData - - # Create a new handle for the copy - cpHandle = theProject.tree._makeHandle() - cpData = copy.deepcopy(scData) - cpData["itemAttr"]["handle"] = cpHandle - - # Check that it is indeed changed - cpItem.setHandle(cpHandle) - assert cpItem.pack() != scData assert cpItem.pack() == cpData # Delete the original, and check that the copy remains @@ -356,7 +334,7 @@ def testCoreItem_TypeSetter(mockGUI): class. """ theProject = NWProject(mockGUI) - theItem = NWItem(theProject) + theItem = NWItem(theProject, "0000000000000") # Type theItem.setType(None) @@ -385,7 +363,7 @@ def testCoreItem_ClassSetter(mockGUI): class. """ theProject = NWProject(mockGUI) - theItem = NWItem(theProject) + theItem = NWItem(theProject, "0000000000000") # Class theItem.setClass(None) @@ -472,7 +450,7 @@ def testCoreItem_LayoutSetter(mockGUI): class. """ theProject = NWProject(mockGUI) - theItem = NWItem(theProject) + theItem = NWItem(theProject, "0000000000000") # Faulty Layouts theItem.setLayout(None) @@ -500,7 +478,7 @@ def testCoreItem_ClassDefaults(mockGUI): """Test the setter for the default values. """ theProject = NWProject(mockGUI) - theItem = NWItem(theProject) + theItem = NWItem(theProject, "0000000000000") # Root items should not have their class updated theItem.setParent(None) @@ -553,18 +531,17 @@ def testCoreItem_ClassDefaults(mockGUI): @pytest.mark.core def testCoreItem_PackUnpack(mockGUI, caplog, mockRnd): - """Test packing and unpacking entries for the NWItem class. - """ + """Test packing and unpacking entries for the NWItem class.""" theProject = NWProject(mockGUI) theProject.data.itemStatus.write(None, "New", (100, 100, 100)) theProject.data.itemImport.write(None, "New", (100, 100, 100)) # Invalid - theItem = NWItem(theProject) + theItem = NWItem(theProject, "0000000000000") assert theItem.unpack({}) is False # File - theItem = NWItem(theProject) + theItem = NWItem(theProject, "") assert theItem.unpack({ "name": "A File", "itemAttr": { @@ -636,7 +613,7 @@ def testCoreItem_PackUnpack(mockGUI, caplog, mockRnd): } # Folder - theItem = NWItem(theProject) + theItem = NWItem(theProject, "") assert theItem.unpack({ "name": "A Folder", "itemAttr": { @@ -701,7 +678,7 @@ def testCoreItem_PackUnpack(mockGUI, caplog, mockRnd): } # Root - theItem = NWItem(theProject) + theItem = NWItem(theProject, "") assert theItem.unpack({ "name": "A Novel", "itemAttr": { diff --git a/tests/test_core/test_core_project.py b/tests/test_core/test_core_project.py index aec33ce7..3e0e7b11 100644 --- a/tests/test_core/test_core_project.py +++ b/tests/test_core/test_core_project.py @@ -25,10 +25,11 @@ from shutil import copyfile from zipfile import ZipFile from mocked import causeOSError -from tools import C, cmpFiles, writeFile, buildTestProject, XML_IGNORE +from tools import C, cmpFiles, buildTestProject, XML_IGNORE from novelwriter import CONFIG -from novelwriter.enum import nwItemClass, nwItemType, nwItemLayout +from novelwriter.enum import nwItemClass +from novelwriter.constants import nwFiles from novelwriter.core.tree import NWTree from novelwriter.core.index import NWIndex from novelwriter.core.project import NWProject @@ -58,7 +59,7 @@ def testCoreProject_NewRoot(fncPath, tstPaths, mockGUI, mockRnd): assert theProject.projChanged is True assert theProject.saveProject() is True - assert theProject.closeProject() is True + theProject.closeProject() copyfile(projFile, testFile) assert cmpFiles(testFile, compFile, ignoreStart=XML_IGNORE) @@ -154,7 +155,7 @@ def testCoreProject_NewFileFolder(monkeypatch, fncPath, tstPaths, mockGUI, mockR assert "0000000000011" not in theProject.tree assert "0000000000012" not in theProject.tree - assert theProject.closeProject() is True + theProject.closeProject() # END Test testCoreProject_NewFileFolder @@ -172,7 +173,8 @@ def testCoreProject_Open(monkeypatch, caplog, mockGUI, fncPath, mockRnd): assert theProject.openProject(fncPath) is False # Fail on lock file - assert theProject._storage.writeLockFile() + theProject.storage._lockFilePath = fncPath / nwFiles.PROJ_LOCK + assert theProject.storage.writeLockFile() is True assert theProject.openProject(fncPath) is False assert isinstance(theProject.getLockStatus(), list) @@ -182,12 +184,13 @@ def testCoreProject_Open(monkeypatch, caplog, mockGUI, fncPath, mockRnd): caplog.clear() assert theProject.openProject(fncPath) is True assert "Failed to check lock file" in caplog.text - assert theProject.closeProject() + theProject.closeProject() # Force open with lockfile - assert theProject._storage.writeLockFile() + theProject.storage._lockFilePath = fncPath / nwFiles.PROJ_LOCK + assert theProject.storage.writeLockFile() is True assert theProject.openProject(fncPath, overrideLock=True) is True - assert theProject.closeProject() + theProject.closeProject() assert theProject.getLockStatus() is None # Fail getting xml reader @@ -237,7 +240,7 @@ def testCoreProject_Open(monkeypatch, caplog, mockGUI, fncPath, mockRnd): mp.setattr("novelwriter.core.tree.NWTree.updateItemData", lambda *a: False) assert theProject.openProject(fncPath) is True - assert theProject.closeProject() + theProject.closeProject() # Trigger an index rebuild with monkeypatch.context() as mp: @@ -249,7 +252,7 @@ def testCoreProject_Open(monkeypatch, caplog, mockGUI, fncPath, mockRnd): assert "The file format of your project is about to be" in mockGUI.lastQuestion[1] assert theProject.index._indexBroken is False - assert theProject.closeProject() + theProject.closeProject() # END Test testCoreProject_Open @@ -278,7 +281,7 @@ def testCoreProject_Save(monkeypatch, mockGUI, mockRnd, fncPath): # Save with and without autosave assert theProject.saveProject(autoSave=False) is True assert theProject.saveProject(autoSave=True) is True - assert theProject.closeProject() + theProject.closeProject() # END Test testCoreProject_Save @@ -316,7 +319,7 @@ def testCoreProject_AccessItems(mockGUI, fncPath, mockRnd): C.hWorldRoot, ] assert theProject.tree.handles() == oldOrder - assert theProject.setTreeOrder(newOrder) + theProject.setTreeOrder(newOrder) assert theProject.tree.handles() == newOrder # Add a non-existing item @@ -451,7 +454,7 @@ def testCoreProject_StatusImport(mockGUI, fncPath, mockRnd): assert len(theProject.data.itemStatus) == 0 assert len(theProject.data.itemImport) == 0 assert theProject.saveProject() is True - assert theProject.closeProject() is True + theProject.closeProject() # END Test testCoreProject_StatusImport @@ -509,9 +512,9 @@ def testCoreProject_Methods(monkeypatch, mockGUI, fncPath, mockRnd): # Project Language theProject.setProjectChanged(False) theProject.data.setLanguage("en") - assert theProject.setProjectLang(None) is True + theProject.setProjectLang(None) assert theProject.data.language is None - assert theProject.setProjectLang("en_GB") is True + theProject.setProjectLang("en_GB") assert theProject.data.language == "en_GB" # Language Lookup @@ -562,97 +565,14 @@ def testCoreProject_Methods(monkeypatch, mockGUI, fncPath, mockRnd): "000000000000e", "000000000000f", ] assert theProject.tree.handles() == oldOrder - assert theProject.setTreeOrder(newOrder) + theProject.setTreeOrder(newOrder) assert theProject.tree.handles() == newOrder - assert theProject.setTreeOrder(oldOrder) + theProject.setTreeOrder(oldOrder) assert theProject.tree.handles() == oldOrder # END Test testCoreProject_Methods -@pytest.mark.core -def testCoreProject_OrphanedFiles(mockGUI, prjLipsum): - """Check that files in the content folder that are not tracked in - the project XML file are handled correctly by the orphaned files - function. It should also restore as much meta data as possible from - the meta line at the top of the document file. - """ - theProject = NWProject(mockGUI) - - assert theProject.openProject(prjLipsum) is True - assert theProject.tree["636b6aa9b697b"] is None - - # Add a file with non-existent parent - # This file will be removed from the project on open - oHandle = theProject.newFile("Oops", "b3643d0f92e32") - theProject.tree[oHandle].setParent("1234567890abc") - - # Save and close - assert theProject.saveProject() is True - assert theProject.closeProject() is True - - # First Item with Meta Data - orphPath = prjLipsum / "content" / "636b6aa9b697b.nwd" - writeFile(orphPath, ( - "%%~name:[Recovered] Mars\n" - "%%~path:5eaea4e8cdee8/636b6aa9b697b\n" - "%%~kind:WORLD/NOTE\n" - "%%~invalid\n" - "\n" - )) - - # Second Item without Meta Data - orphPath = prjLipsum / "content" / "736b6aa9b697b.nwd" - writeFile(orphPath, "\n") - - # Invalid File Name - tstPath = prjLipsum / "content" / "636b6aa9b697b.txt" - writeFile(tstPath, "\n") - - # Invalid File Name - tstPath = prjLipsum / "content" / "636b6aa9b697bb.nwd" - writeFile(tstPath, "\n") - - # Invalid File Name - tstPath = prjLipsum / "content" / "abcdefghijklm.nwd" - writeFile(tstPath, "\n") - - assert theProject.openProject(prjLipsum) - assert theProject.storage.storagePath is not None - assert theProject.storage.runtimePath is not None - assert theProject.tree["636b6aa9b697bb"] is None - assert theProject.tree["abcdefghijklm"] is None - - # First Item with Meta Data - oItem = theProject.tree["636b6aa9b697b"] - assert oItem is not None - assert oItem.itemName == "[Recovered] Mars" - assert oItem.itemHandle == "636b6aa9b697b" - assert oItem.itemParent == "60bdf227455cc" - assert oItem.itemClass == nwItemClass.WORLD - assert oItem.itemType == nwItemType.FILE - assert oItem.itemLayout == nwItemLayout.NOTE - - # Second Item without Meta Data - oItem = theProject.tree["736b6aa9b697b"] - assert oItem is not None - assert oItem.itemName == "Recovered File 1" - assert oItem.itemHandle == "736b6aa9b697b" - assert oItem.itemParent == "b3643d0f92e32" - assert oItem.itemClass == nwItemClass.NOVEL - assert oItem.itemType == nwItemType.FILE - assert oItem.itemLayout == nwItemLayout.NOTE - - assert theProject.saveProject(prjLipsum) - assert theProject.closeProject() - - # Finally, check that the orphaned files function returns - # if no project is open and no path is set - assert not theProject._scanProjectFolder() - -# END Test testCoreProject_OrphanedFiles - - @pytest.mark.core def testCoreProject_Backup(monkeypatch, mockGUI, fncPath, tstPaths): """Test the automated backup feature of the project class. The test diff --git a/tests/test_core/test_core_projectxml.py b/tests/test_core/test_core_projectxml.py index 1e233ee4..3448e653 100644 --- a/tests/test_core/test_core_projectxml.py +++ b/tests/test_core/test_core_projectxml.py @@ -140,6 +140,7 @@ def testCoreProjectXML_ReadCurrent(monkeypatch, tstPaths, fncPath): assert xmlReader.state == XMLReadState.PARSED_OK assert xmlReader.xmlRoot == "novelWriterXML" assert xmlReader.xmlVersion == 0x0105 + assert xmlReader.xmlRevision == 1 assert xmlReader.appVersion == "2.0-rc1" assert xmlReader.hexVersion == 0x020000c1 @@ -213,7 +214,7 @@ def testCoreProjectXML_ReadCurrent(monkeypatch, tstPaths, fncPath): mockProject = MockProject() mockProject.__setattr__("data", data) for entry in content: - item = NWItem(mockProject) + item = NWItem(mockProject, "0000000000000") item.unpack(entry) packedContent.append(item.pack()) @@ -333,7 +334,7 @@ def testCoreProjectXML_ReadLegacy10(tstPaths, fncPath, mockRnd): mockProject.__setattr__("data", data) status = {} for entry in content: - item = NWItem(mockProject) + item = NWItem(mockProject, "0000000000000") item.unpack(entry) status[item.itemHandle] = item.getImportStatus(incIcon=False)[0] packedContent.append(item.pack()) @@ -468,7 +469,7 @@ def testCoreProjectXML_ReadLegacy11(tstPaths, fncPath, mockRnd): mockProject.__setattr__("data", data) status = {} for entry in content: - item = NWItem(mockProject) + item = NWItem(mockProject, "0000000000000") item.unpack(entry) status[item.itemHandle] = item.getImportStatus(incIcon=False)[0] packedContent.append(item.pack()) @@ -603,7 +604,7 @@ def testCoreProjectXML_ReadLegacy12(tstPaths, fncPath, mockRnd): mockProject.__setattr__("data", data) status = {} for entry in content: - item = NWItem(mockProject) + item = NWItem(mockProject, "0000000000000") item.unpack(entry) status[item.itemHandle] = item.getImportStatus(incIcon=False)[0] packedContent.append(item.pack()) @@ -741,7 +742,7 @@ def testCoreProjectXML_ReadLegacy13(tstPaths, fncPath, mockRnd): mockProject.__setattr__("data", data) status = {} for entry in content: - item = NWItem(mockProject) + item = NWItem(mockProject, "0000000000000") item.unpack(entry) status[item.itemHandle] = item.getImportStatus(incIcon=False)[0] packedContent.append(item.pack()) @@ -879,7 +880,7 @@ def testCoreProjectXML_ReadLegacy14(tstPaths, fncPath, mockRnd): mockProject.__setattr__("data", data) status = {} for entry in content: - item = NWItem(mockProject) + item = NWItem(mockProject, "0000000000000") item.unpack(entry) status[item.itemHandle] = item.getImportStatus(incIcon=False)[0] packedContent.append(item.pack()) diff --git a/tests/test_core/test_core_storage.py b/tests/test_core/test_core_storage.py index 38871beb..77db582b 100644 --- a/tests/test_core/test_core_storage.py +++ b/tests/test_core/test_core_storage.py @@ -63,6 +63,7 @@ def testCoreStorage_OpenProjectInPlace(mockGUI, fncPath, mockRnd): assert storage.getXmlWriter() is None assert bool(storage.getDocument(C.hSceneDoc)) is False assert storage.getMetaFile("file") is None + assert storage.scanContent() == [] # Open project as a new project should fail assert storage.openProjectInPlace(fncPath, newProject=True) is False @@ -90,6 +91,9 @@ def testCoreStorage_OpenProjectInPlace(mockGUI, fncPath, mockRnd): assert isinstance(storage.getXmlReader(), ProjectXMLReader) assert isinstance(storage.getXmlWriter(), ProjectXMLWriter) + # Get content + assert sorted(storage.scanContent()) == [C.hTitlePage, C.hChapterDoc, C.hSceneDoc] + # Get document assert storage.getDocument(C.hSceneDoc).readDocument() == "### New Scene\n\n" @@ -97,7 +101,7 @@ def testCoreStorage_OpenProjectInPlace(mockGUI, fncPath, mockRnd): assert storage.getMetaFile("stuff") == fncPath / "meta" / "stuff" # Clean up - assert theProject.closeProject() is True + theProject.closeProject() # Check closed project return values (again) assert storage.isOpen() is False diff --git a/tests/test_core/test_core_tree.py b/tests/test_core/test_core_tree.py index 1f894a8f..c2301bf7 100644 --- a/tests/test_core/test_core_tree.py +++ b/tests/test_core/test_core_tree.py @@ -18,16 +18,18 @@ General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see . """ +from __future__ import annotations import pytest import random from pathlib import Path +from tools import C, buildTestProject from mocked import causeOSError -from tools import readFile from novelwriter.enum import nwItemClass, nwItemType, nwItemLayout +from novelwriter.common import isHandle from novelwriter.constants import nwFiles from novelwriter.core.item import NWItem from novelwriter.core.tree import NWTree @@ -39,20 +41,23 @@ def mockItems(mockGUI, mockRnd): """Create a list of mock items.""" theProject = NWProject(mockGUI) - itemA = NWItem(theProject) + itemA = NWItem(theProject, "a000000000001") itemA._name = "Novel" + itemA._parent = None itemA._type = nwItemType.ROOT itemA._class = nwItemClass.NOVEL itemA._expanded = True - itemB = NWItem(theProject) + itemB = NWItem(theProject, "b000000000001") itemB._name = "Act One" + itemB._parent = "a000000000001" itemB._type = nwItemType.FOLDER itemB._class = nwItemClass.NOVEL itemB._expanded = True - itemC = NWItem(theProject) + itemC = NWItem(theProject, "c000000000001") itemC._name = "Chapter One" + itemC._parent = "b000000000001" itemC._type = nwItemType.FILE itemC._class = nwItemClass.NOVEL itemC._layout = nwItemLayout.DOCUMENT @@ -60,8 +65,9 @@ def mockItems(mockGUI, mockRnd): itemC._wordCount = 50 itemC._paraCount = 2 - itemD = NWItem(theProject) + itemD = NWItem(theProject, "c000000000002") itemD._name = "Scene One" + itemD._parent = "b000000000001" itemD._type = nwItemType.FILE itemD._class = nwItemClass.NOVEL itemD._layout = nwItemLayout.DOCUMENT @@ -69,26 +75,30 @@ def mockItems(mockGUI, mockRnd): itemD._wordCount = 500 itemD._paraCount = 20 - itemE = NWItem(theProject) + itemE = NWItem(theProject, "a000000000002") itemE._name = "Outtakes" + itemE._parent = None itemE._type = nwItemType.ROOT itemE._class = nwItemClass.ARCHIVE itemE._expanded = False - itemF = NWItem(theProject) + itemF = NWItem(theProject, "a000000000003") itemF._name = "Trash" + itemF._parent = None itemF._type = nwItemType.ROOT itemF._class = nwItemClass.TRASH itemF._expanded = False - itemG = NWItem(theProject) + itemG = NWItem(theProject, "a000000000004") itemG._name = "Characters" + itemG._parent = None itemG._type = nwItemType.ROOT itemG._class = nwItemClass.CHARACTER itemG._expanded = True - itemH = NWItem(theProject) + itemH = NWItem(theProject, "b000000000002") itemH._name = "Jane Doe" + itemH._parent = "a000000000004" itemH._type = nwItemType.FILE itemH._class = nwItemClass.CHARACTER itemH._layout = nwItemLayout.NOTE @@ -96,18 +106,7 @@ def mockItems(mockGUI, mockRnd): itemH._wordCount = 400 itemH._paraCount = 16 - theItems = [ - ("a000000000001", None, itemA), - ("b000000000001", "a000000000001", itemB), - ("c000000000001", "b000000000001", itemC), - ("c000000000002", "b000000000001", itemD), - ("a000000000002", None, itemE), - ("a000000000003", None, itemF), - ("a000000000004", None, itemG), - ("b000000000002", "a000000000004", itemH), - ] - - return theItems + return [itemA, itemB, itemC, itemD, itemE, itemF, itemG, itemH] @pytest.mark.core @@ -123,10 +122,10 @@ def testCoreTree_BuildTree(mockGUI, mockItems): assert theTree.trashRoot() is None aHandles = [] - for tHandle, pHandle, nwItem in mockItems: - aHandles.append(tHandle) - assert theTree.append(tHandle, pHandle, nwItem) is True - assert theTree.updateItemData(tHandle) is True + for nwItem in mockItems: + aHandles.append(nwItem.itemHandle) + assert theTree.append(nwItem) is True + assert theTree.updateItemData(nwItem.itemHandle) is True assert theTree._treeChanged is True @@ -143,6 +142,9 @@ def testCoreTree_BuildTree(mockGUI, mockItems): for theItem, theHandle in zip(theTree, aHandles): assert theItem.itemHandle == theHandle + # Trash Folder + # ============ + # Check that we have the correct archive and trash folders assert theTree.trashRoot() == "a000000000003" assert theTree.findRoot(nwItemClass.ARCHIVE) == "a000000000002" @@ -157,57 +159,93 @@ def testCoreTree_BuildTree(mockGUI, mockItems): assert theTree.isTrash("0000000000000") is True # Doesn't exist assert theTree.isTrash("a000000000003") is True # This the trash folder - theTree["a000000000003"].setClass(nwItemClass.NO_CLASS) + theTree["a000000000003"].setClass(nwItemClass.NO_CLASS) # type: ignore assert theTree.isTrash("a000000000003") is True # This is still trash - theTree["a000000000003"].setClass(nwItemClass.TRASH) + theTree["a000000000003"].setClass(nwItemClass.TRASH) # type: ignore assert theTree.isTrash("b000000000002") is False # This is not trash - value = theTree["b000000000002"].itemParent - theTree["b000000000002"].setParent("a000000000003") + value = theTree["b000000000002"].itemParent # type: ignore + theTree["b000000000002"].setParent("a000000000003") # type: ignore assert theTree.isTrash("b000000000002") is True # This is in trash - theTree["b000000000002"].setParent(value) + theTree["b000000000002"].setParent(value) # type: ignore - value = theTree["b000000000002"].itemRoot - theTree["b000000000002"].setRoot("a000000000003") + value = theTree["b000000000002"].itemRoot # type: ignore + theTree["b000000000002"].setRoot("a000000000003") # type: ignore assert theTree.isTrash("b000000000002") is True # This is in trash - theTree["b000000000002"].setRoot(value) + theTree["b000000000002"].setRoot(value) # type: ignore # Try to add another trash folder - itemT = NWItem(theProject) + itemT = NWItem(theProject, "1111111111111") itemT._name = "Trash" itemT._type = nwItemType.ROOT itemT._class = nwItemClass.TRASH itemT._expanded = False - assert theTree.append("1234567890abc", None, itemT) is False + assert theTree.append(itemT) is False assert len(theTree) == len(mockItems) - # Generate handle automatically - itemT = NWItem(theProject) - itemT._name = "New File" - itemT._type = nwItemType.FILE - itemT._class = nwItemClass.NOVEL - itemT._layout = nwItemLayout.DOCUMENT + # Create or Add Items + # =================== - assert theTree.append(None, None, itemT) is True - assert theTree.updateItemData(itemT.itemHandle) is True - assert len(theTree) == len(mockItems) + 1 + # Create a new item, but with invalid parent + assert theTree.create("New File", "blabla", nwItemType.FILE, nwItemClass.NO_CLASS) is None + # Create a new, valid item + nHandle = theTree.create("New File", "b000000000001", nwItemType.FILE, nwItemClass.NO_CLASS) + assert isHandle(nHandle) + assert nHandle == "0000000000000" + + # The new item should be the last item in the tree theList = theTree.handles() - nHandle = "0000000000000" assert theList[-1] == nHandle - # Try to add existing handle - assert theTree.append(nHandle, None, itemT) is False + # Retrieve the item + itemT = theTree[nHandle] + assert isinstance(itemT, NWItem) assert len(theTree) == len(mockItems) + 1 + # We should not be allowed to add the item again + assert theTree.append(itemT) is False + assert len(theTree) == len(mockItems) + 1 + + # Create an invalid item to add, which will be rejected + itemU = NWItem.duplicate(itemT, "blabla") + assert theTree.append(itemU) is False + assert len(theTree) == len(mockItems) + 1 + + # Create a new root, but with a parent set anyway (the parent should be ignored) + zHandle = theTree.create("Custom", "a000000000001", nwItemType.ROOT, nwItemClass.CUSTOM) + assert isinstance(zHandle, str) + itemZ = theTree[zHandle] + assert isinstance(itemZ, NWItem) + assert itemZ.itemParent is None + del theTree[zHandle] + + # Duplicate Items + # =============== + + # Duplicate a non-existing item + assert theTree.duplicate("blabla") is None + + # Duplicate the new item + itemV = theTree.duplicate(nHandle) + assert isinstance(itemV, NWItem) + assert len(theTree) == len(mockItems) + 2 + + dHandle = itemV.itemHandle + assert dHandle == "0000000000002" + + # Delete Items + # ============ + # Delete a non-existing item del theTree["stuff"] - assert len(theTree) == len(mockItems) + 1 + assert len(theTree) == len(mockItems) + 2 - # Delete the last item + # Delete the last items del theTree[nHandle] + del theTree[dHandle] assert len(theTree) == len(mockItems) assert nHandle not in theTree @@ -235,17 +273,17 @@ def testCoreTree_PackUnpack(mockGUI, mockItems): theTree = NWTree(theProject) aHandles = [] - for tHandle, pHandle, nwItem in mockItems: - aHandles.append(tHandle) - theTree.append(tHandle, pHandle, nwItem) - theTree.updateItemData(tHandle) + for nwItem in mockItems: + aHandles.append(nwItem.itemHandle) + theTree.append(nwItem) + theTree.updateItemData(nwItem.itemHandle) assert len(theTree) == len(mockItems) # Pack tree = theTree.pack() - for i, (tHandle, pHandle, nwItem) in enumerate(mockItems): - assert tree[i]["itemAttr"]["handle"] == tHandle + for i, nwItem in enumerate(mockItems): + assert tree[i]["itemAttr"]["handle"] == nwItem.itemHandle # Unpack theTree.clear() @@ -257,15 +295,82 @@ def testCoreTree_PackUnpack(mockGUI, mockItems): # END Test testCoreTree_PackUnpack +@pytest.mark.core +def testCoreTree_CheckConsistency(caplog: pytest.LogCaptureFixture, mockGUI, fncPath, mockRnd): + """Check the project consistency.""" + theProject = NWProject(mockGUI) + buildTestProject(theProject, fncPath) + + # By default, all is well + caplog.clear() + assert theProject.tree.checkConsistency("Recovered") == (0, 0) + assert all(m.endswith("OK") for m in caplog.messages) + + # Give the scene file an unknown parent + caplog.clear() + theProject.tree[C.hSceneDoc].setParent(C.hInvalid) # type: ignore + assert theProject.tree.checkConsistency("Recovered") == (1, 1) + assert f"'{C.hSceneDoc}' ... ERROR" in caplog.text + + # The scene file should have been added back to its home + itemS = theProject.tree[C.hSceneDoc] + assert isinstance(itemS, NWItem) + assert itemS.itemParent == C.hChapterDir + + # Create a new file with no meta data, and let the function handle it as orphaned + xHandle = "0123456789abc" + contentPath = theProject.storage.contentPath + assert isinstance(contentPath, Path) + assert contentPath == fncPath / "content" + (contentPath / f"{xHandle}.nwd").write_text("### Stuff", encoding="utf-8") + + assert theProject.tree.checkConsistency("Recovered") == (1, 1) + assert xHandle in theProject.tree + itemX = theProject.tree[xHandle] + assert isinstance(itemX, NWItem) + + # It should by default be added as a Novel file + assert itemX.itemParent == C.hNovelRoot + assert itemX.itemRoot == C.hNovelRoot + assert itemX.itemClass == nwItemClass.NOVEL + assert itemX.itemName == "[Recovered] 0123456789abc" + + # Set an unknown class in the orphaned item + itemX.setClass(nwItemClass.OBJECT) + itemX.setName("Stuff") + itemX.setParent(C.hInvalid) + theProject.storage.getDocument(xHandle).writeDocument("### Stuff") # This adds meta data + + # Remove the item in the project, and re-run the consistency check + del theProject.tree[xHandle] + assert theProject.tree.checkConsistency("Recovered") == (1, 1) + assert xHandle in theProject.tree + itemX = theProject.tree[xHandle] + assert isinstance(itemX, NWItem) + + # It should again be added as a Novel file + assert itemX.itemParent == C.hNovelRoot + assert itemX.itemRoot == C.hNovelRoot + assert itemX.itemClass == nwItemClass.NOVEL + assert itemX.itemName == "[Recovered] Stuff" + + # If the tree is empty, there is nowhere to add any of the 4 files + theProject.tree.clear() + assert theProject.tree.checkConsistency("Recovered") == (4, 0) + assert len(theProject.tree) == 0 + +# END Test testCoreTree_CheckConsistency + + @pytest.mark.core def testCoreTree_Methods(mockGUI, mockItems): """Test various class methods.""" theProject = NWProject(mockGUI) theTree = NWTree(theProject) - for tHandle, pHandle, nwItem in mockItems: - theTree.append(tHandle, pHandle, nwItem) - theTree.updateItemData(tHandle) + for nwItem in mockItems: + theTree.append(nwItem) + theTree.updateItemData(nwItem.itemHandle) assert len(theTree) == len(mockItems) @@ -273,22 +378,22 @@ def testCoreTree_Methods(mockGUI, mockItems): assert theTree.updateItemData("stuff") is False # Update item data, invalid item parent - corrParent = theTree["b000000000001"].itemParent - theTree["b000000000001"].setParent("0000000000000") + corrParent = theTree["b000000000001"].itemParent # type: ignore + theTree["b000000000001"].setParent("0000000000000") # type: ignore assert theTree.updateItemData("b000000000001") is False # Update item data, valid item parent - theTree["b000000000001"].setParent(corrParent) + theTree["b000000000001"].setParent(corrParent) # type: ignore assert theTree.updateItemData("b000000000001") is True # Update item data, root is unreachable maxDepth = theTree.MAX_DEPTH - theTree.MAX_DEPTH = 0 + theTree.MAX_DEPTH = 0 # type: ignore with pytest.raises(RecursionError): theTree.updateItemData("b000000000001") theTree.MAX_DEPTH = maxDepth - # Chech type + # Check type assert theTree.checkType("blabla", nwItemType.FILE) is False assert theTree.checkType("b000000000001", nwItemType.FILE) is False assert theTree.checkType("c000000000001", nwItemType.FILE) is True @@ -306,7 +411,7 @@ def testCoreTree_Methods(mockGUI, mockItems): assert roots[3][0] == "a000000000004" # Add a fake item to root and check that it can handle it - theTree._treeRoots["0000000000000"] = NWItem(theProject) + theTree._treeRoots["0000000000000"] = NWItem(theProject, "0000000000000") assert theTree.findRoot(nwItemClass.WORLD) is None del theTree._treeRoots["0000000000000"] @@ -318,18 +423,18 @@ def testCoreTree_Methods(mockGUI, mockItems): # Cause recursion error maxDepth = theTree.MAX_DEPTH - theTree.MAX_DEPTH = 0 + theTree.MAX_DEPTH = 0 # type: ignore with pytest.raises(RecursionError): theTree.getItemPath("c000000000001") theTree.MAX_DEPTH = maxDepth # Break the folder parent handle - theTree["b000000000001"]._parent = "stuff" + theTree["b000000000001"]._parent = "stuff" # type: ignore assert theTree.getItemPath("c000000000001") == [ "c000000000001", "b000000000001" ] - theTree["b000000000001"]._parent = "a000000000001" + theTree["b000000000001"]._parent = "a000000000001" # type: ignore assert theTree.getItemPath("c000000000001") == [ "c000000000001", "b000000000001", "a000000000001" ] @@ -349,13 +454,13 @@ def testCoreTree_MakeHandles(mockGUI): random.seed(42) tHandle = theTree._makeHandle() assert tHandle == handles[0] - theTree._projTree[handles[0]] = None + theTree._projTree[handles[0]] = None # type: ignore # Add the next in line to the project to force duplicate - theTree._projTree[handles[1]] = None + theTree._projTree[handles[1]] = None # type: ignore tHandle = theTree._makeHandle() assert tHandle == handles[2] - theTree._projTree[handles[2]] = None + theTree._projTree[handles[2]] = None # type: ignore # Reset the seed to force collissions, which should still end up # returning the next handle in the sequence @@ -372,8 +477,8 @@ def testCoreTree_Stats(mockGUI, mockItems): theProject = NWProject(mockGUI) theTree = NWTree(theProject) - for tHandle, pHandle, nwItem in mockItems: - theTree.append(tHandle, pHandle, nwItem) + for nwItem in mockItems: + theTree.append(nwItem) assert len(theTree) == len(mockItems) theTree._treeOrder.append("stuff") @@ -393,9 +498,9 @@ def testCoreTree_Reorder(caplog, mockGUI, mockItems): theTree = NWTree(theProject) aHandle = [] - for tHandle, pHandle, nwItem in mockItems: - aHandle.append(tHandle) - theTree.append(tHandle, pHandle, nwItem) + for nwItem in mockItems: + aHandle.append(nwItem.itemHandle) + theTree.append(nwItem) assert len(theTree) == len(mockItems) @@ -422,14 +527,14 @@ def testCoreTree_Reorder(caplog, mockGUI, mockItems): @pytest.mark.core -def testCoreTree_ToCFile(monkeypatch, tstPaths, mockGUI, mockItems): +def testCoreTree_ToCFile(monkeypatch, fncPath, mockGUI, mockItems): """Test writing the ToC.txt file.""" theProject = NWProject(mockGUI) theTree = NWTree(theProject) - for tHandle, pHandle, nwItem in mockItems: - theTree.append(tHandle, pHandle, nwItem) - theTree.updateItemData(tHandle) + for nwItem in mockItems: + theTree.append(nwItem) + theTree.updateItemData(nwItem.itemHandle) assert len(theTree) == len(mockItems) theTree._treeOrder.append("stuff") @@ -443,24 +548,27 @@ def testCoreTree_ToCFile(monkeypatch, tstPaths, mockGUI, mockItems): return dItem.itemType == nwItemType.FILE monkeypatch.setattr("pathlib.Path.is_file", mockIsFile) + theProject._storage._runtimePath = fncPath + (fncPath / "content").mkdir() - theProject._storage._runtimePath = None - assert theTree.writeToCFile() is False + # Block extraction of the path + with monkeypatch.context() as mp: + mp.setattr("novelwriter.core.storage.NWStorage.contentPath", lambda *a: None) + assert theTree.writeToCFile() is False - theProject._storage._runtimePath = tstPaths.tmpDir + # Block opening the file with monkeypatch.context() as mp: mp.setattr("builtins.open", causeOSError) assert theTree.writeToCFile() is False - theProject._storage._runtimePath = tstPaths.tmpDir - (tstPaths.tmpDir / "content").mkdir() + # Allow writing assert theTree.writeToCFile() is True pathA = str(Path("content") / "c000000000001.nwd") pathB = str(Path("content") / "c000000000002.nwd") pathC = str(Path("content") / "b000000000002.nwd") - assert readFile(tstPaths.tmpDir / nwFiles.TOC_TXT) == ( + assert (fncPath / nwFiles.TOC_TXT).read_text() == ( "\n" "Table of Contents\n" "=================\n"