Improve project item and tree classes (#1482)

This commit is contained in:
Veronica Berglyd Olsen
2023-07-26 19:06:45 +02:00
committed by GitHub
19 changed files with 542 additions and 580 deletions
-2
View File
@@ -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
+1 -1
View File
@@ -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:
-2
View File
@@ -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
+9 -3
View File
@@ -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.
"""
+2 -2
View File
@@ -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)
+43 -40
View File
@@ -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"<NWItem handle={self._handle}, parent={self._parent}, name='{self._name}'>"
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:
+3 -4
View File
@@ -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
+90 -235
View File
@@ -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
+15 -4
View File
@@ -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()
+115 -18
View File
@@ -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 <https://www.gnu.org/licenses/>.
"""
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)
+4 -5
View File
@@ -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):
@@ -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
+3 -8
View File
@@ -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
])
+5 -5
View File
@@ -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
+27 -50
View File
@@ -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) == "<NWItem handle=1234567890abc, parent=4567890abcdef, name='New Item'>"
theItem.setParent("1111111111111")
assert repr(theItem) == "<NWItem handle=0000000000000, parent=1111111111111, name='New Item'>"
# 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": {
+20 -100
View File
@@ -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
+7 -6
View File
@@ -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())
+5 -1
View File
@@ -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
+193 -85
View File
@@ -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 <https://www.gnu.org/licenses/>.
"""
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"