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: for item in project.tree:
tHandle = item.itemHandle tHandle = item.itemHandle
if tHandle is None:
continue
if item.isInactiveClass() or (item.itemRoot in self._skipRoot): if item.isInactiveClass() or (item.itemRoot in self._skipRoot):
result[tHandle] = (False, FilterMode.SKIPPED) result[tHandle] = (False, FilterMode.SKIPPED)
continue continue
+1 -1
View File
@@ -287,7 +287,7 @@ class DocDuplicator:
hMap: dict[str, str | None] = {t: None for t in items} hMap: dict[str, str | None] = {t: None for t in items}
for tHandle in items: for tHandle in items:
newItem = self._project.tree.duplicate(tHandle) newItem = self._project.tree.duplicate(tHandle)
if newItem is None or newItem.itemHandle is None: if newItem is None:
return return
hMap[tHandle] = newItem.itemHandle hMap[tHandle] = newItem.itemHandle
if newItem.itemParent in hMap: if newItem.itemParent in hMap:
-2
View File
@@ -101,8 +101,6 @@ class NWBuildDocument:
self._queue = [] self._queue = []
filtered = self._build.buildItemFilter(self._project) filtered = self._build.buildItemFilter(self._project)
for item in self._project.tree: for item in self._project.tree:
if not item.itemHandle:
continue
if filtered.get(item.itemHandle, False): if filtered.get(item.itemHandle, False):
self._queue.append(item.itemHandle) self._queue.append(item.itemHandle)
return return
+9 -3
View File
@@ -1,7 +1,6 @@
""" """
novelWriter Project Document novelWriter Project Document
============================== ==============================
Data class for a single novelWriter document
File History: File History:
Created: 2018-09-29 [0.0.1] Created: 2018-09-29 [0.0.1]
@@ -41,8 +40,15 @@ logger = logging.getLogger(__name__)
class NWDocument: 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 self._project = project
@@ -243,7 +249,7 @@ class NWDocument:
"""Return a pointer to the currently open NWItem.""" """Return a pointer to the currently open NWItem."""
return self._theItem 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, """Parse the document meta tag and return the name, parent,
class and layout meta values. class and layout meta values.
""" """
+2 -2
View File
@@ -76,7 +76,7 @@ class NWIndex:
a rebuild of the index data. a rebuild of the index data.
""" """
def __init__(self, project): def __init__(self, project: NWProject):
self._project = project self._project = project
@@ -197,7 +197,7 @@ class NWIndex:
logger.debug("Checking index") logger.debug("Checking index")
# Check that all files are indexed # 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: if fHandle not in self._itemIndex:
logger.warning("Item '%s' is not in the index", fHandle) logger.warning("Item '%s' is not in the index", fHandle)
self.reIndexHandle(fHandle) self.reIndexHandle(fHandle)
+43 -40
View File
@@ -1,7 +1,6 @@
""" """
novelWriter Project Item Class novelWriter Project Item Class
================================ ================================
Data class for a project tree item
File History: File History:
Created: 2018-10-27 [0.0.1] Created: 2018-10-27 [0.0.1]
@@ -43,6 +42,13 @@ logger = logging.getLogger(__name__)
class NWItem: 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__ = ( __slots__ = (
"_project", "_name", "_handle", "_parent", "_root", "_order", "_project", "_name", "_handle", "_parent", "_root", "_order",
@@ -51,11 +57,11 @@ class NWItem:
"_paraCount", "_cursorPos", "_initCount", "_paraCount", "_cursorPos", "_initCount",
) )
def __init__(self, project: NWProject) -> None: def __init__(self, project: NWProject, handle: str) -> None:
self._project = project self._project = project
self._name = "" self._name = ""
self._handle = None self._handle = handle
self._parent = None self._parent = None
self._root = None self._root = None
self._order = 0 self._order = 0
@@ -81,31 +87,12 @@ class NWItem:
return f"<NWItem handle={self._handle}, parent={self._parent}, name='{self._name}'>" return f"<NWItem handle={self._handle}, parent={self._parent}, name='{self._name}'>"
def __bool__(self) -> bool: def __bool__(self) -> bool:
"""Evaluate to False if itemHandle is not set.""" """The truthiness of the class. The handle used to be initiated
return self._handle is not None to None, but this is no longer the case. It should always
evaluate to True since 2.1-beta1, although unpack and the NWTree
def __copy__(self) -> NWItem: class can leave it as an empty string.
"""Make a shallow copy of the current item.""" """
item = NWItem(self._project) return bool(self._handle)
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
## ##
# Properties # Properties
@@ -116,7 +103,7 @@ class NWItem:
return self._name return self._name
@property @property
def itemHandle(self) -> str | None: def itemHandle(self) -> str:
return self._handle return self._handle
@property @property
@@ -184,7 +171,7 @@ class NWItem:
return self._cursorPos return self._cursorPos
## ##
# Pack/Unpack Data # Pack/Unpack/Duplicate Data
## ##
def pack(self) -> dict: def pack(self) -> dict:
@@ -227,8 +214,9 @@ class NWItem:
meta = data.get("metaAttr", {}) meta = data.get("metaAttr", {})
name = data.get("nameAttr", {}) name = data.get("nameAttr", {})
if "handle" in item: handle = item.get("handle", "")
self.setHandle(item["handle"]) if isHandle(handle):
self._handle = handle
else: else:
logger.error("Item does not have a handle") logger.error("Item does not have a handle")
return False return False
@@ -269,6 +257,29 @@ class NWItem:
return True 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 # Lookup Methods
## ##
@@ -387,14 +398,6 @@ class NWItem:
self._name = "" self._name = ""
return 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: def setParent(self, handle: Any) -> None:
"""Set the parent handle, and ensure it is valid.""" """Set the parent handle, and ensure it is valid."""
if handle is None: if handle is None:
+3 -4
View File
@@ -77,7 +77,7 @@ class OptionState:
the Config instead. the Config instead.
""" """
def __init__(self, project: NWProject): def __init__(self, project: NWProject) -> None:
self._project = project self._project = project
self._state = {} self._state = {}
return return
@@ -87,8 +87,7 @@ class OptionState:
## ##
def loadSettings(self) -> bool: 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) stateFile = self._project.storage.getMetaFile(nwFiles.OPTS_FILE)
if not isinstance(stateFile, Path): if not isinstance(stateFile, Path):
return False return False
@@ -116,7 +115,7 @@ class OptionState:
return True return True
def saveSettings(self) -> bool: 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) stateFile = self._project.storage.getMetaFile(nwFiles.OPTS_FILE)
if not isinstance(stateFile, Path): if not isinstance(stateFile, Path):
return False return False
+90 -235
View File
@@ -28,6 +28,7 @@ import json
import logging import logging
from time import time from time import time
from typing import TYPE_CHECKING, Iterator
from pathlib import Path from pathlib import Path
from functools import partial from functools import partial
@@ -38,7 +39,6 @@ from novelwriter.enum import nwItemType, nwItemClass, nwItemLayout, nwAlert
from novelwriter.error import logException from novelwriter.error import logException
from novelwriter.constants import trConst, nwLabels from novelwriter.constants import trConst, nwLabels
from novelwriter.core.tree import NWTree from novelwriter.core.tree import NWTree
from novelwriter.core.item import NWItem
from novelwriter.core.index import NWIndex from novelwriter.core.index import NWIndex
from novelwriter.core.options import OptionState from novelwriter.core.options import OptionState
from novelwriter.core.storage import NWStorage 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.projectxml import ProjectXMLReader, ProjectXMLWriter, XMLReadState
from novelwriter.core.projectdata import NWProjectData from novelwriter.core.projectdata import NWProjectData
from novelwriter.common import ( 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__) logger = logging.getLogger(__name__)
@@ -56,7 +61,7 @@ class NWProject(QObject):
projectStatusChanged = pyqtSignal(bool) projectStatusChanged = pyqtSignal(bool)
def __init__(self, mainGui): def __init__(self, mainGui: GuiMain) -> None:
super().__init__(parent=mainGui) super().__init__(parent=mainGui)
# Internal # Internal
@@ -70,20 +75,14 @@ class NWProject(QObject):
self._index = NWIndex(self) # The project index self._index = NWIndex(self) # The project index
self._session = NWSessionLog(self) # The session record self._session = NWSessionLog(self) # The session record
# Data Cache
self._langData = {} # Localisation data
# Project Status # Project Status
self._langData = {} # Localisation data
self._projChanged = False # The project has unsaved changes self._projChanged = False # The project has unsaved changes
self._lockedBy = None # Data on which computer has the project open 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 # Internal Mapping
self.tr = partial(QCoreApplication.translate, "NWProject") self.tr = partial(QCoreApplication.translate, "NWProject")
# Set Defaults
self.clearProject()
return return
## ##
@@ -91,23 +90,23 @@ class NWProject(QObject):
## ##
@property @property
def options(self): def options(self) -> OptionState:
return self._options return self._options
@property @property
def storage(self): def storage(self) -> NWStorage:
return self._storage return self._storage
@property @property
def data(self): def data(self) -> NWProjectData:
return self._data return self._data
@property @property
def tree(self): def tree(self) -> NWTree:
return self._tree return self._tree
@property @property
def index(self): def index(self) -> NWIndex:
return self._index return self._index
@property @property
@@ -115,59 +114,33 @@ class NWProject(QObject):
return self._session return self._session
@property @property
def projOpened(self): def projOpened(self) -> float:
return self._session.start return self._session.start
@property @property
def projChanged(self): def projChanged(self) -> bool:
return self._projChanged return self._projChanged
@property
def projFiles(self):
return self._projFiles
## ##
# Item Methods # Item Methods
## ##
def newRoot(self, itemClass, label=None): def newRoot(self, itemClass: nwItemClass, label: str | None = None) -> str:
"""Add a new root item. If label is None, use the class label. """Add a new root folder to the project. If label is not set,
use the class label.
""" """
if label is None: label = label or trConst(nwLabels.CLASS_NAME[itemClass])
label = trConst(nwLabels.CLASS_NAME[itemClass]) return self._tree.create(label, None, nwItemType.ROOT, 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
def newFolder(self, label, pHandle): def newFolder(self, label: str, parent: str) -> str | None:
"""Add a new folder with a given label and parent item. """Add a new folder with a given label and parent item."""
""" return self._tree.create(label, parent, nwItemType.FOLDER)
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 newFile(self, label, pHandle): def newFile(self, label: str, parent: str) -> str | None:
"""Add a new file with a given label and parent item. """Add a new file with a given label and parent item."""
""" return self._tree.create(label, parent, nwItemType.FILE)
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 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 """Write content to a new document after it is created. This
will not run if the file exists and is not empty. will not run if the file exists and is not empty.
""" """
@@ -193,7 +166,7 @@ class NWProject(QObject):
return True return True
def removeItem(self, tHandle): def removeItem(self, tHandle: str) -> bool:
"""Remove an item from the project. This will delete both the """Remove an item from the project. This will delete both the
project entry and a document file if it exists. project entry and a document file if it exists.
""" """
@@ -210,45 +183,40 @@ class NWProject(QObject):
return True return True
def trashFolder(self): def trashFolder(self) -> str:
"""Add the special trash root folder to the project. """Add the special trash root folder to the project."""
"""
trashHandle = self._tree.trashRoot() trashHandle = self._tree.trashRoot()
if trashHandle is None: if trashHandle is None:
newItem = NWItem(self) label = trConst(nwLabels.CLASS_NAME[nwItemClass.TRASH])
newItem.setName(trConst(nwLabels.CLASS_NAME[nwItemClass.TRASH])) return self._tree.create(label, None, nwItemType.ROOT, nwItemClass.TRASH)
newItem.setType(nwItemType.ROOT)
newItem.setClass(nwItemClass.TRASH)
self._tree.append(None, None, newItem)
self._tree.updateItemData(newItem.itemHandle)
return newItem.itemHandle
return trashHandle return trashHandle
## ##
# Project Methods # Project Methods
## ##
def clearProject(self): def clearProject(self) -> None:
"""Clear the data for the current project, and set them to """Clear the data for the current project, and set them to
default values. 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._storage.clear()
self._data = NWProjectData(self)
self._tree.clear() self._tree.clear()
self._index.clearIndex() self._index.clearIndex()
self._data = NWProjectData(self)
self._session = NWSessionLog(self) self._session = NWSessionLog(self)
# Project Settings # Project Status
self._projFiles = [] self._langData = {}
self._projChanged = False
return 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 """Open the project file provided. If it doesn't exist, assume
it is a folder and look for the file within it. If successful, it is a folder and look for the file within it. If successful,
parse the XML of the file and populate the project variables and parse the XML of the file and populate the project variables and
@@ -360,15 +328,13 @@ class NWProject(QObject):
) )
# Check the project tree consistency # Check the project tree consistency
for tItem in self._tree: # This also handles any orphaned files found
if tItem: orphans, recovered = self._tree.checkConsistency(self.tr("Recovered"))
tHandle = tItem.itemHandle if orphans > 0:
logger.debug("Checking item '%s'", tHandle) self.mainGui.makeAlert(self.tr(
if not self._tree.updateItemData(tHandle): "Found {0} orphaned file(s) in the project. {1} file(s) were recovered."
logger.error("There was a problem the item, and it has been removed") ).format(orphans, recovered), nwAlert.WARN)
del self._tree[tHandle] # The file will be re-added as orphaned
self._scanProjectFolder()
self._index.loadIndex() self._index.loadIndex()
if xmlReader.state == XMLReadState.WAS_LEGACY: if xmlReader.state == XMLReadState.WAS_LEGACY:
# Often, the index needs to be rebuilt when updating format # Often, the index needs to be rebuilt when updating format
@@ -382,7 +348,7 @@ class NWProject(QObject):
return True return True
def saveProject(self, autoSave=False): def saveProject(self, autoSave: bool = False) -> bool:
"""Save the project main XML file. The saving command itself """Save the project main XML file. The saving command itself
uses a temporary filename, and the file is replaced afterwards uses a temporary filename, and the file is replaced afterwards
to make sure if the save fails, we're not left with a truncated to make sure if the save fails, we're not left with a truncated
@@ -435,22 +401,19 @@ class NWProject(QObject):
return True return True
def closeProject(self, idleTime=0.0): def closeProject(self, idleTime: float = 0.0) -> None:
"""Close the current project and clear all meta data. """Close the current project and clear all meta data."""
"""
logger.info("Closing project") logger.info("Closing project")
self._options.saveSettings() self._options.saveSettings()
self._tree.writeToCFile() self._tree.writeToCFile()
self._session.appendSession(idleTime) self._session.appendSession(idleTime)
self._storage.clearLockFile()
self._storage.closeSession() self._storage.closeSession()
self.clearProject() self.clearProject()
self._lockedBy = None self._lockedBy = None
return True return
def backupProject(self, doNotify): def backupProject(self, doNotify: bool) -> bool:
"""Create a zip file of the entire project. """Create a zip file of the entire project."""
"""
if not self._storage.isOpen(): if not self._storage.isOpen():
logger.error("No project open") logger.error("No project open")
return False return False
@@ -507,9 +470,8 @@ class NWProject(QObject):
# Setters # Setters
## ##
def setDefaultStatusImport(self): def setDefaultStatusImport(self) -> None:
"""Set the default status and importance values. """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("New"), (100, 100, 100))
self._data.itemStatus.write(None, self.tr("Note"), (200, 50, 0)) self._data.itemStatus.write(None, self.tr("Note"), (200, 50, 0))
self._data.itemStatus.write(None, self.tr("Draft"), (200, 150, 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)) self._data.itemImport.write(None, self.tr("Main"), (50, 200, 0))
return return
def setProjectLang(self, theLang): def setProjectLang(self, language: str | None) -> None:
"""Set the project-specific language. """Set the project-specific language."""
""" language = checkStringNone(language, None)
theLang = checkStringNone(theLang, None) if self._data.language != language:
if self._data.language != theLang: self._data.setLanguage(language)
self._data.setLanguage(theLang)
self._loadProjectLocalisation() self._loadProjectLocalisation()
self.setProjectChanged(True) 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 """A list representing the linear/flattened order of project
items in the GUI project tree. The user can rearrange the order items in the GUI project tree. The user can rearrange the order
by drag-and-drop. Forwarded to the NWTree class. 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") logger.warning("Sizes of new and old tree order do not match")
self._tree.setOrder(newOrder) self._tree.setOrder(order)
self.setProjectChanged(True) self.setProjectChanged(True)
return True return
def setStatusColours(self, newCols, delCols): def setStatusColours(self, new: list[dict], deleted: list[str]) -> bool:
"""Update the list of novel file status flags. """Update the list of novel file status flags."""
""" return self._setStatusImport(new, deleted, self._data.itemStatus)
return self._setStatusImport(newCols, delCols, self._data.itemStatus)
def setImportColours(self, newCols, delCols): def setImportColours(self, new: list[dict], deleted: list[str]) -> bool:
"""Update the list of note file importance flags. """Update the list of note file importance flags."""
""" return self._setStatusImport(new, deleted, self._data.itemImport)
return self._setStatusImport(newCols, delCols, self._data.itemImport)
def setProjectChanged(self, value): def setProjectChanged(self, status: bool) -> bool:
"""Toggle the project changed flag, and propagate the """Toggle the project changed flag, and propagate the
information to the GUI statusbar. information to the GUI statusbar.
""" """
if isinstance(value, bool): if isinstance(status, bool):
self._projChanged = value self._projChanged = status
self.projectStatusChanged.emit(self._projChanged) self.projectStatusChanged.emit(self._projChanged)
return self._projChanged return self._projChanged
@@ -576,7 +535,7 @@ class NWProject(QObject):
""" """
return self._data.editTime + round(time() - self._session.start) 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 """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 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, 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) logger.error("Item '%s' has no parent in current tree", tHandle)
tItem.setParent(None) tItem.setParent(None)
yield tItem yield tItem
return
## ##
# Class Methods # Class Methods
## ##
def updateWordCounts(self): def updateWordCounts(self) -> None:
"""Update the total word count values. """Update the total word count values."""
"""
novel, notes = self._tree.sumWords() novel, notes = self._tree.sumWords()
self._data.setCurrCounts(novel=novel, notes=notes) self._data.setCurrCounts(novel=novel, notes=notes)
return return
def countStatus(self): def countStatus(self) -> None:
"""Count how many times the various status flags are used in the """Count how many times the various status flags are used in the
project tree. The counts themselves are kept in the NWStatus project tree. The counts themselves are kept in the NWStatus
objects. This is essentially a refresh. objects. This is essentially a refresh.
@@ -643,18 +602,18 @@ class NWProject(QObject):
self._data.itemImport.increment(nwItem.itemImport) self._data.itemImport.increment(nwItem.itemImport)
return return
def localLookup(self, theWord): def localLookup(self, word: str | int) -> str:
"""Look up a word in the translation map for the project and """Look up a word or number in the translation map for the
return it. The variable is cast to a string before lookup. If project and return it. The variable is cast to a string before
the word does not exist, it returns itself. 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 # 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 """Update the list of novel file status or importance flags, and
delete those that have been requested deleted. delete those that have been requested deleted.
""" """
@@ -676,9 +635,8 @@ class NWProject(QObject):
return True return True
def _loadProjectLocalisation(self): def _loadProjectLocalisation(self) -> bool:
"""Load the language data for the current project language. """Load the language data for the current project language."""
"""
if self._data.language is None or CONFIG._nwLangPath is None: if self._data.language is None or CONFIG._nwLangPath is None:
self._langData = {} self._langData = {}
return False return False
@@ -691,7 +649,6 @@ class NWProject(QObject):
with open(langFile, mode="r", encoding="utf-8") as inFile: with open(langFile, mode="r", encoding="utf-8") as inFile:
self._langData = json.load(inFile) self._langData = json.load(inFile)
logger.debug("Loaded project language file: %s", langFile.name) logger.debug("Loaded project language file: %s", langFile.name)
except Exception: except Exception:
logger.error("Failed to project language file") logger.error("Failed to project language file")
logException() logException()
@@ -699,106 +656,4 @@ class NWProject(QObject):
return True 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 # 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 import CONFIG
from novelwriter.error import logException from novelwriter.error import logException
from novelwriter.common import minmax from novelwriter.common import isHandle, minmax
from novelwriter.constants import nwFiles from novelwriter.constants import nwFiles
from novelwriter.core.document import NWDocument from novelwriter.core.document import NWDocument
from novelwriter.core.projectxml import ProjectXMLReader, ProjectXMLWriter from novelwriter.core.projectxml import ProjectXMLReader, ProjectXMLWriter
@@ -67,6 +67,7 @@ class NWStorage:
"""Reset internal variables.""" """Reset internal variables."""
self._storagePath = None self._storagePath = None
self._runtimePath = None self._runtimePath = None
self._lockFilePath = None
self._openMode = self.MODE_INACTIVE self._openMode = self.MODE_INACTIVE
return return
@@ -146,7 +147,7 @@ class NWStorage:
def closeSession(self): def closeSession(self):
"""Run tasks related to closing the session.""" """Run tasks related to closing the session."""
# Clear lockfile self.clearLockFile()
self.clear() self.clear()
return return
@@ -179,7 +180,17 @@ class NWStorage:
return self._runtimePath / "meta" / fileName return self._runtimePath / "meta" / fileName
return None 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.""" """Read the project lock file."""
if self._lockFilePath is None: if self._lockFilePath is None:
return ["ERROR"] return ["ERROR"]
@@ -188,7 +199,7 @@ class NWStorage:
return [] return []
try: try:
lines = self._lockFilePath.read_text(encoding="utf-8").split(";") lines = self._lockFilePath.read_text(encoding="utf-8").strip().split(";")
except Exception: except Exception:
logger.error("Failed to read project lockfile") logger.error("Failed to read project lockfile")
logException() logException()
+115 -18
View File
@@ -1,7 +1,6 @@
""" """
novelWriter Project Tree Class novelWriter Project Tree Class
================================ ================================
Data class for the project's tree of project items
File History: File History:
Created: 2020-05-07 [0.4.5] 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 from __future__ import annotations
import copy
import random import random
import logging import logging
from typing import TYPE_CHECKING, Iterator from typing import TYPE_CHECKING, Iterator, Literal, overload
from pathlib import Path from pathlib import Path
from novelwriter.enum import nwItemClass, nwItemLayout, nwItemType from novelwriter.enum import nwItemClass, nwItemLayout, nwItemType
from novelwriter.error import logException from novelwriter.error import logException
from novelwriter.common import checkHandle from novelwriter.common import isHandle
from novelwriter.constants import nwFiles from novelwriter.constants import nwFiles
from novelwriter.core.item import NWItem from novelwriter.core.item import NWItem
@@ -44,6 +42,22 @@ logger = logging.getLogger(__name__)
class NWTree: 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 MAX_DEPTH = 1000 # Cap of tree traversing for loops
@@ -52,7 +66,7 @@ class NWTree:
self._project = project self._project = project
self._projTree: dict[str, NWItem] = {} # Holds all the items of the 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._treeRoots: dict[str, NWItem] = {} # The root items of the tree
self._trashRoot = None # The handle of the trash root folder 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.""" """Returns a copy of the list of all the active handles."""
return self._treeOrder.copy() return self._treeOrder.copy()
def append(self, tHandle: str | None, pHandle: str | None, nwItem: NWItem) -> bool: @overload
"""Add a new item to the end of the tree.""" def create(self, label: str, parent: None, itemType: Literal[nwItemType.ROOT],
tHandle = checkHandle(tHandle, None, True) itemClass: nwItemClass) -> str: # pragma: no cover
pHandle = checkHandle(pHandle, None, True) pass
if tHandle is None:
@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() 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: if tHandle in self._projTree:
logger.warning("Duplicate handle '%s' detected, skipping", tHandle) 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)) logger.debug("Adding item '%s' with parent '%s'", str(tHandle), str(pHandle))
nwItem.setHandle(tHandle)
nwItem.setParent(pHandle)
if nwItem.isRootType(): if nwItem.isRootType():
logger.debug("Item '%s' is a root item", str(tHandle)) logger.debug("Item '%s' is a root item", str(tHandle))
self._treeRoots[tHandle] = nwItem self._treeRoots[tHandle] = nwItem
@@ -119,8 +160,8 @@ class NWTree:
"""Duplicate an item and set a new handle.""" """Duplicate an item and set a new handle."""
sItem = self.__getitem__(sHandle) sItem = self.__getitem__(sHandle)
if isinstance(sItem, NWItem): if isinstance(sItem, NWItem):
nItem = copy.copy(sItem) nItem = NWItem.duplicate(sItem, self._makeHandle())
if self.append(None, sItem.itemParent, nItem): if self.append(nItem):
logger.info("Duplicated item '%s' -> '%s'", sHandle, nItem.itemHandle) logger.info("Duplicated item '%s' -> '%s'", sHandle, nItem.itemHandle)
return nItem return nItem
return None return None
@@ -142,12 +183,68 @@ class NWTree:
""" """
self.clear() self.clear()
for item in data: for item in data:
nwItem = NWItem(self._project) nwItem = NWItem(self._project, "") # Handle is set by unpack()
if nwItem.unpack(item): if nwItem.unpack(item):
self.append(nwItem.itemHandle, nwItem.itemParent, nwItem) self.append(nwItem)
nwItem.saveInitialCount() nwItem.saveInitialCount()
return 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: def writeToCFile(self) -> bool:
"""Write the convenience table of contents file in the root of """Write the convenience table of contents file in the root of
the project directory. the project directory.
@@ -314,7 +411,7 @@ class NWTree:
return self._trashRoot return self._trashRoot
return None 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.""" """Find the first root item for a given class."""
for aRoot in self._treeRoots: for aRoot in self._treeRoots:
tItem = self.__getitem__(aRoot) tItem = self.__getitem__(aRoot)
+4 -5
View File
@@ -1046,13 +1046,12 @@ class GuiProjectTree(QTreeWidget):
""" """
logger.debug("Building the project tree ...") logger.debug("Building the project tree ...")
self.clearTree() self.clearTree()
count = 0
iCount = 0
for nwItem in self.theProject.getProjectItems(): for nwItem in self.theProject.getProjectItems():
iCount += 1 count += 1
self._addTreeItem(nwItem) self._addTreeItem(nwItem)
if count > 0:
logger.debug("%d item(s) added to the project tree", iCount) logger.info("%d item(s) added to the project tree", count)
return return
def undoLastMove(self): def undoLastMove(self):
@@ -31,7 +31,6 @@ from mocked import causeOSError
from novelwriter.enum import nwBuildFmt, nwItemClass from novelwriter.enum import nwBuildFmt, nwItemClass
from novelwriter.constants import nwFiles from novelwriter.constants import nwFiles
from novelwriter.core.item import NWItem
from novelwriter.core.project import NWProject from novelwriter.core.project import NWProject
from novelwriter.core.buildsettings import BuildCollection, BuildSettings, FilterMode 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") hArchRoot = project.newRoot(nwItemClass.ARCHIVE, "Archive")
hPlotDoc = project.newFile("Main Plot", C.hPlotRoot) hPlotDoc = project.newFile("Main Plot", C.hPlotRoot)
hCharDoc = project.newFile("Jane Doe", C.hCharRoot) hCharDoc = project.newFile("Jane Doe", C.hCharRoot)
initLen = len(project.tree)
# With no changes # With no changes
assert build.isRootAllowed(C.hNovelRoot) is True assert build.isRootAllowed(C.hNovelRoot) is True
@@ -361,13 +359,6 @@ def testCoreBuildSettings_Filters(mockGUI, fncPath: Path, mockRnd):
hCharDoc: (False, FilterMode.FILTERED), 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 # No valid project provided
assert build.buildItemFilter(None) == {} # type: ignore 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 mocked import causeException, causeOSError
from novelwriter.enum import nwBuildFmt from novelwriter.enum import nwBuildFmt
from novelwriter.core.item import NWItem
from novelwriter.core.tomd import ToMarkdown from novelwriter.core.tomd import ToMarkdown
from novelwriter.core.toodt import ToOdt from novelwriter.core.toodt import ToOdt
from novelwriter.core.tohtml import ToHtml from novelwriter.core.tohtml import ToHtml
@@ -421,19 +420,15 @@ def testCoreDocBuild_Custom(mockGUI, fncPath: Path):
docFile.unlink() docFile.unlink()
# Add an invalid item to the project # Add an invalid item to the project
bHandle = "0123456789abc"
nHandle = "0123456789def" nHandle = "0123456789def"
project.tree._treeOrder.append(bHandle)
project.tree._projTree[bHandle] = NWItem(project) # Handle should be None
project.tree._treeOrder.append(nHandle) project.tree._treeOrder.append(nHandle)
project.tree._projTree[nHandle] = None project.tree._projTree[nHandle] = None # type: ignore
docBuild.queueAll() docBuild.queueAll()
assert len(docBuild) == 8 assert len(docBuild) == 8
docBuild.addDocument(bHandle)
docBuild.addDocument(nHandle) docBuild.addDocument(nHandle)
assert len(docBuild) == 10 assert len(docBuild) == 9
# Build the doc again with broken items # Build the doc again with broken items
count = 0 count = 0
@@ -472,7 +467,7 @@ def testCoreDocBuild_IterBuild(mockGUI, fncPath: Path, mockRnd):
project.storage.getDocument(hCharDoc).writeDocument("# Jane Doe\n~~Text~~") project.storage.getDocument(hCharDoc).writeDocument("# Jane Doe\n~~Text~~")
# Fix project order as this has never been opened in a GUI # 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.hNovelRoot, C.hTitlePage, C.hChapterDir, C.hChapterDoc, C.hSceneDoc,
C.hPlotRoot, hPlotDoc, C.hCharRoot, hCharDoc, C.hWorldRoot 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 assert "7a992350f3eb6" in theIndex._itemIndex
# Finalise # Finalise
assert theProject.closeProject() is True theProject.closeProject()
# END Test testCoreIndex_LoadSave # END Test testCoreIndex_LoadSave
@@ -195,7 +195,7 @@ def testCoreIndex_ScanThis(mockGUI):
assert theBits == ["@tag", "this", "and this"] assert theBits == ["@tag", "this", "and this"]
assert thePos == [0, 6, 12] assert thePos == [0, 6, 12]
assert theProject.closeProject() is True theProject.closeProject()
# END Test testCoreIndex_ScanThis # 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(["@who", "Jane", "John"], cItem) == [False, False, False]
assert theIndex.checkThese(["@pov", "Jane", "John"], nItem) == [True, True, False] assert theIndex.checkThese(["@pov", "Jane", "John"], nItem) == [True, True, False]
assert theProject.closeProject() is True theProject.closeProject()
# END Test testCoreIndex_CheckThese # 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"].paraCount == 1
assert theIndex._itemIndex[pHandle]["T0000"].synopsis == "" assert theIndex._itemIndex[pHandle]["T0000"].synopsis == ""
assert theProject.closeProject() is True theProject.closeProject()
# END Test testCoreIndex_ScanText # END Test testCoreIndex_ScanText
@@ -774,7 +774,7 @@ def testCoreIndex_ExtractData(mockGUI, fncPath, mockRnd):
assert theIndex.saveIndex() is True assert theIndex.saveIndex() is True
assert theProject.saveProject() is True assert theProject.saveProject() is True
assert theProject.closeProject() is True theProject.closeProject()
# END Test testCoreIndex_ExtractData # END Test testCoreIndex_ExtractData
+27 -50
View File
@@ -37,7 +37,8 @@ def testCoreItem_Setters(mockGUI, mockRnd, fncPath):
theProject = NWProject(mockGUI) theProject = NWProject(mockGUI)
mockRnd.reset() mockRnd.reset()
buildTestProject(theProject, fncPath) buildTestProject(theProject, fncPath)
theItem = NWItem(theProject) theItem = NWItem(theProject, "0000000000000")
assert theItem.itemHandle == "0000000000000"
statusKeys = ["s000000", "s000001", "s000002", "s000003"] statusKeys = ["s000000", "s000001", "s000002", "s000003"]
importKeys = ["i000004", "i000005", "i000006", "i000007"] importKeys = ["i000004", "i000005", "i000006", "i000007"]
@@ -52,16 +53,6 @@ def testCoreItem_Setters(mockGUI, mockRnd, fncPath):
theItem.setName(123) theItem.setName(123)
assert theItem.itemName == "" 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 # Parent
theItem.setParent(None) theItem.setParent(None)
assert theItem.itemParent is None assert theItem.itemParent is None
@@ -197,7 +188,7 @@ def testCoreItem_Methods(mockGUI, mockRnd, fncPath):
theProject = NWProject(mockGUI) theProject = NWProject(mockGUI)
mockRnd.reset() mockRnd.reset()
buildTestProject(theProject, fncPath) buildTestProject(theProject, fncPath)
theItem = NWItem(theProject) theItem = NWItem(theProject, "0000000000000")
# Describe Me # Describe Me
# =========== # ===========
@@ -270,24 +261,15 @@ def testCoreItem_Methods(mockGUI, mockRnd, fncPath):
# ============== # ==============
theItem.setName("New Item") theItem.setName("New Item")
theItem.setHandle("1234567890abc") theItem.setParent("1111111111111")
theItem.setParent("4567890abcdef") assert repr(theItem) == "<NWItem handle=0000000000000, parent=1111111111111, name='New Item'>"
assert repr(theItem) == "<NWItem handle=1234567890abc, parent=4567890abcdef, name='New Item'>"
# Truthiness # Truthiness
# ========== # ==========
bItem = NWItem(theProject) # Is True if the handle evaluates to True
assert bool(NWItem(theProject, "0000000000000")) is True
# An item with a handle is valid assert bool(NWItem(theProject, "")) is False
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
# Copy an Item # Copy an Item
# ============ # ============
@@ -318,29 +300,25 @@ def testCoreItem_Methods(mockGUI, mockRnd, fncPath):
} }
} }
# Get the scene item
scItem = theProject.tree[C.hSceneDoc] scItem = theProject.tree[C.hSceneDoc]
cpItem = copy.copy(scItem)
# We should have two instances of NWItem
assert isinstance(scItem, 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 isinstance(cpItem, NWItem)
assert scItem is not cpItem assert scItem is not cpItem
# They should both point to the same project instance # They should both point to the same project instance
assert scItem._project is cpItem._project 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 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 assert cpItem.pack() == cpData
# Delete the original, and check that the copy remains # Delete the original, and check that the copy remains
@@ -356,7 +334,7 @@ def testCoreItem_TypeSetter(mockGUI):
class. class.
""" """
theProject = NWProject(mockGUI) theProject = NWProject(mockGUI)
theItem = NWItem(theProject) theItem = NWItem(theProject, "0000000000000")
# Type # Type
theItem.setType(None) theItem.setType(None)
@@ -385,7 +363,7 @@ def testCoreItem_ClassSetter(mockGUI):
class. class.
""" """
theProject = NWProject(mockGUI) theProject = NWProject(mockGUI)
theItem = NWItem(theProject) theItem = NWItem(theProject, "0000000000000")
# Class # Class
theItem.setClass(None) theItem.setClass(None)
@@ -472,7 +450,7 @@ def testCoreItem_LayoutSetter(mockGUI):
class. class.
""" """
theProject = NWProject(mockGUI) theProject = NWProject(mockGUI)
theItem = NWItem(theProject) theItem = NWItem(theProject, "0000000000000")
# Faulty Layouts # Faulty Layouts
theItem.setLayout(None) theItem.setLayout(None)
@@ -500,7 +478,7 @@ def testCoreItem_ClassDefaults(mockGUI):
"""Test the setter for the default values. """Test the setter for the default values.
""" """
theProject = NWProject(mockGUI) theProject = NWProject(mockGUI)
theItem = NWItem(theProject) theItem = NWItem(theProject, "0000000000000")
# Root items should not have their class updated # Root items should not have their class updated
theItem.setParent(None) theItem.setParent(None)
@@ -553,18 +531,17 @@ def testCoreItem_ClassDefaults(mockGUI):
@pytest.mark.core @pytest.mark.core
def testCoreItem_PackUnpack(mockGUI, caplog, mockRnd): 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 = NWProject(mockGUI)
theProject.data.itemStatus.write(None, "New", (100, 100, 100)) theProject.data.itemStatus.write(None, "New", (100, 100, 100))
theProject.data.itemImport.write(None, "New", (100, 100, 100)) theProject.data.itemImport.write(None, "New", (100, 100, 100))
# Invalid # Invalid
theItem = NWItem(theProject) theItem = NWItem(theProject, "0000000000000")
assert theItem.unpack({}) is False assert theItem.unpack({}) is False
# File # File
theItem = NWItem(theProject) theItem = NWItem(theProject, "")
assert theItem.unpack({ assert theItem.unpack({
"name": "A File", "name": "A File",
"itemAttr": { "itemAttr": {
@@ -636,7 +613,7 @@ def testCoreItem_PackUnpack(mockGUI, caplog, mockRnd):
} }
# Folder # Folder
theItem = NWItem(theProject) theItem = NWItem(theProject, "")
assert theItem.unpack({ assert theItem.unpack({
"name": "A Folder", "name": "A Folder",
"itemAttr": { "itemAttr": {
@@ -701,7 +678,7 @@ def testCoreItem_PackUnpack(mockGUI, caplog, mockRnd):
} }
# Root # Root
theItem = NWItem(theProject) theItem = NWItem(theProject, "")
assert theItem.unpack({ assert theItem.unpack({
"name": "A Novel", "name": "A Novel",
"itemAttr": { "itemAttr": {
+20 -100
View File
@@ -25,10 +25,11 @@ from shutil import copyfile
from zipfile import ZipFile from zipfile import ZipFile
from mocked import causeOSError 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 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.tree import NWTree
from novelwriter.core.index import NWIndex from novelwriter.core.index import NWIndex
from novelwriter.core.project import NWProject from novelwriter.core.project import NWProject
@@ -58,7 +59,7 @@ def testCoreProject_NewRoot(fncPath, tstPaths, mockGUI, mockRnd):
assert theProject.projChanged is True assert theProject.projChanged is True
assert theProject.saveProject() is True assert theProject.saveProject() is True
assert theProject.closeProject() is True theProject.closeProject()
copyfile(projFile, testFile) copyfile(projFile, testFile)
assert cmpFiles(testFile, compFile, ignoreStart=XML_IGNORE) 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 "0000000000011" not in theProject.tree
assert "0000000000012" not in theProject.tree assert "0000000000012" not in theProject.tree
assert theProject.closeProject() is True theProject.closeProject()
# END Test testCoreProject_NewFileFolder # END Test testCoreProject_NewFileFolder
@@ -172,7 +173,8 @@ def testCoreProject_Open(monkeypatch, caplog, mockGUI, fncPath, mockRnd):
assert theProject.openProject(fncPath) is False assert theProject.openProject(fncPath) is False
# Fail on lock file # 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 theProject.openProject(fncPath) is False
assert isinstance(theProject.getLockStatus(), list) assert isinstance(theProject.getLockStatus(), list)
@@ -182,12 +184,13 @@ def testCoreProject_Open(monkeypatch, caplog, mockGUI, fncPath, mockRnd):
caplog.clear() caplog.clear()
assert theProject.openProject(fncPath) is True assert theProject.openProject(fncPath) is True
assert "Failed to check lock file" in caplog.text assert "Failed to check lock file" in caplog.text
assert theProject.closeProject() theProject.closeProject()
# Force open with lockfile # 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.openProject(fncPath, overrideLock=True) is True
assert theProject.closeProject() theProject.closeProject()
assert theProject.getLockStatus() is None assert theProject.getLockStatus() is None
# Fail getting xml reader # 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) mp.setattr("novelwriter.core.tree.NWTree.updateItemData", lambda *a: False)
assert theProject.openProject(fncPath) is True assert theProject.openProject(fncPath) is True
assert theProject.closeProject() theProject.closeProject()
# Trigger an index rebuild # Trigger an index rebuild
with monkeypatch.context() as mp: 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 "The file format of your project is about to be" in mockGUI.lastQuestion[1]
assert theProject.index._indexBroken is False assert theProject.index._indexBroken is False
assert theProject.closeProject() theProject.closeProject()
# END Test testCoreProject_Open # END Test testCoreProject_Open
@@ -278,7 +281,7 @@ def testCoreProject_Save(monkeypatch, mockGUI, mockRnd, fncPath):
# Save with and without autosave # Save with and without autosave
assert theProject.saveProject(autoSave=False) is True assert theProject.saveProject(autoSave=False) is True
assert theProject.saveProject(autoSave=True) is True assert theProject.saveProject(autoSave=True) is True
assert theProject.closeProject() theProject.closeProject()
# END Test testCoreProject_Save # END Test testCoreProject_Save
@@ -316,7 +319,7 @@ def testCoreProject_AccessItems(mockGUI, fncPath, mockRnd):
C.hWorldRoot, C.hWorldRoot,
] ]
assert theProject.tree.handles() == oldOrder assert theProject.tree.handles() == oldOrder
assert theProject.setTreeOrder(newOrder) theProject.setTreeOrder(newOrder)
assert theProject.tree.handles() == newOrder assert theProject.tree.handles() == newOrder
# Add a non-existing item # 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.itemStatus) == 0
assert len(theProject.data.itemImport) == 0 assert len(theProject.data.itemImport) == 0
assert theProject.saveProject() is True assert theProject.saveProject() is True
assert theProject.closeProject() is True theProject.closeProject()
# END Test testCoreProject_StatusImport # END Test testCoreProject_StatusImport
@@ -509,9 +512,9 @@ def testCoreProject_Methods(monkeypatch, mockGUI, fncPath, mockRnd):
# Project Language # Project Language
theProject.setProjectChanged(False) theProject.setProjectChanged(False)
theProject.data.setLanguage("en") theProject.data.setLanguage("en")
assert theProject.setProjectLang(None) is True theProject.setProjectLang(None)
assert theProject.data.language is None assert theProject.data.language is None
assert theProject.setProjectLang("en_GB") is True theProject.setProjectLang("en_GB")
assert theProject.data.language == "en_GB" assert theProject.data.language == "en_GB"
# Language Lookup # Language Lookup
@@ -562,97 +565,14 @@ def testCoreProject_Methods(monkeypatch, mockGUI, fncPath, mockRnd):
"000000000000e", "000000000000f", "000000000000e", "000000000000f",
] ]
assert theProject.tree.handles() == oldOrder assert theProject.tree.handles() == oldOrder
assert theProject.setTreeOrder(newOrder) theProject.setTreeOrder(newOrder)
assert theProject.tree.handles() == newOrder assert theProject.tree.handles() == newOrder
assert theProject.setTreeOrder(oldOrder) theProject.setTreeOrder(oldOrder)
assert theProject.tree.handles() == oldOrder assert theProject.tree.handles() == oldOrder
# END Test testCoreProject_Methods # 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 @pytest.mark.core
def testCoreProject_Backup(monkeypatch, mockGUI, fncPath, tstPaths): def testCoreProject_Backup(monkeypatch, mockGUI, fncPath, tstPaths):
"""Test the automated backup feature of the project class. The test """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.state == XMLReadState.PARSED_OK
assert xmlReader.xmlRoot == "novelWriterXML" assert xmlReader.xmlRoot == "novelWriterXML"
assert xmlReader.xmlVersion == 0x0105 assert xmlReader.xmlVersion == 0x0105
assert xmlReader.xmlRevision == 1
assert xmlReader.appVersion == "2.0-rc1" assert xmlReader.appVersion == "2.0-rc1"
assert xmlReader.hexVersion == 0x020000c1 assert xmlReader.hexVersion == 0x020000c1
@@ -213,7 +214,7 @@ def testCoreProjectXML_ReadCurrent(monkeypatch, tstPaths, fncPath):
mockProject = MockProject() mockProject = MockProject()
mockProject.__setattr__("data", data) mockProject.__setattr__("data", data)
for entry in content: for entry in content:
item = NWItem(mockProject) item = NWItem(mockProject, "0000000000000")
item.unpack(entry) item.unpack(entry)
packedContent.append(item.pack()) packedContent.append(item.pack())
@@ -333,7 +334,7 @@ def testCoreProjectXML_ReadLegacy10(tstPaths, fncPath, mockRnd):
mockProject.__setattr__("data", data) mockProject.__setattr__("data", data)
status = {} status = {}
for entry in content: for entry in content:
item = NWItem(mockProject) item = NWItem(mockProject, "0000000000000")
item.unpack(entry) item.unpack(entry)
status[item.itemHandle] = item.getImportStatus(incIcon=False)[0] status[item.itemHandle] = item.getImportStatus(incIcon=False)[0]
packedContent.append(item.pack()) packedContent.append(item.pack())
@@ -468,7 +469,7 @@ def testCoreProjectXML_ReadLegacy11(tstPaths, fncPath, mockRnd):
mockProject.__setattr__("data", data) mockProject.__setattr__("data", data)
status = {} status = {}
for entry in content: for entry in content:
item = NWItem(mockProject) item = NWItem(mockProject, "0000000000000")
item.unpack(entry) item.unpack(entry)
status[item.itemHandle] = item.getImportStatus(incIcon=False)[0] status[item.itemHandle] = item.getImportStatus(incIcon=False)[0]
packedContent.append(item.pack()) packedContent.append(item.pack())
@@ -603,7 +604,7 @@ def testCoreProjectXML_ReadLegacy12(tstPaths, fncPath, mockRnd):
mockProject.__setattr__("data", data) mockProject.__setattr__("data", data)
status = {} status = {}
for entry in content: for entry in content:
item = NWItem(mockProject) item = NWItem(mockProject, "0000000000000")
item.unpack(entry) item.unpack(entry)
status[item.itemHandle] = item.getImportStatus(incIcon=False)[0] status[item.itemHandle] = item.getImportStatus(incIcon=False)[0]
packedContent.append(item.pack()) packedContent.append(item.pack())
@@ -741,7 +742,7 @@ def testCoreProjectXML_ReadLegacy13(tstPaths, fncPath, mockRnd):
mockProject.__setattr__("data", data) mockProject.__setattr__("data", data)
status = {} status = {}
for entry in content: for entry in content:
item = NWItem(mockProject) item = NWItem(mockProject, "0000000000000")
item.unpack(entry) item.unpack(entry)
status[item.itemHandle] = item.getImportStatus(incIcon=False)[0] status[item.itemHandle] = item.getImportStatus(incIcon=False)[0]
packedContent.append(item.pack()) packedContent.append(item.pack())
@@ -879,7 +880,7 @@ def testCoreProjectXML_ReadLegacy14(tstPaths, fncPath, mockRnd):
mockProject.__setattr__("data", data) mockProject.__setattr__("data", data)
status = {} status = {}
for entry in content: for entry in content:
item = NWItem(mockProject) item = NWItem(mockProject, "0000000000000")
item.unpack(entry) item.unpack(entry)
status[item.itemHandle] = item.getImportStatus(incIcon=False)[0] status[item.itemHandle] = item.getImportStatus(incIcon=False)[0]
packedContent.append(item.pack()) packedContent.append(item.pack())
+5 -1
View File
@@ -63,6 +63,7 @@ def testCoreStorage_OpenProjectInPlace(mockGUI, fncPath, mockRnd):
assert storage.getXmlWriter() is None assert storage.getXmlWriter() is None
assert bool(storage.getDocument(C.hSceneDoc)) is False assert bool(storage.getDocument(C.hSceneDoc)) is False
assert storage.getMetaFile("file") is None assert storage.getMetaFile("file") is None
assert storage.scanContent() == []
# Open project as a new project should fail # Open project as a new project should fail
assert storage.openProjectInPlace(fncPath, newProject=True) is False 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.getXmlReader(), ProjectXMLReader)
assert isinstance(storage.getXmlWriter(), ProjectXMLWriter) assert isinstance(storage.getXmlWriter(), ProjectXMLWriter)
# Get content
assert sorted(storage.scanContent()) == [C.hTitlePage, C.hChapterDoc, C.hSceneDoc]
# Get document # Get document
assert storage.getDocument(C.hSceneDoc).readDocument() == "### New Scene\n\n" 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" assert storage.getMetaFile("stuff") == fncPath / "meta" / "stuff"
# Clean up # Clean up
assert theProject.closeProject() is True theProject.closeProject()
# Check closed project return values (again) # Check closed project return values (again)
assert storage.isOpen() is False 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 You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>. along with this program. If not, see <https://www.gnu.org/licenses/>.
""" """
from __future__ import annotations
import pytest import pytest
import random import random
from pathlib import Path from pathlib import Path
from tools import C, buildTestProject
from mocked import causeOSError from mocked import causeOSError
from tools import readFile
from novelwriter.enum import nwItemClass, nwItemType, nwItemLayout from novelwriter.enum import nwItemClass, nwItemType, nwItemLayout
from novelwriter.common import isHandle
from novelwriter.constants import nwFiles from novelwriter.constants import nwFiles
from novelwriter.core.item import NWItem from novelwriter.core.item import NWItem
from novelwriter.core.tree import NWTree from novelwriter.core.tree import NWTree
@@ -39,20 +41,23 @@ def mockItems(mockGUI, mockRnd):
"""Create a list of mock items.""" """Create a list of mock items."""
theProject = NWProject(mockGUI) theProject = NWProject(mockGUI)
itemA = NWItem(theProject) itemA = NWItem(theProject, "a000000000001")
itemA._name = "Novel" itemA._name = "Novel"
itemA._parent = None
itemA._type = nwItemType.ROOT itemA._type = nwItemType.ROOT
itemA._class = nwItemClass.NOVEL itemA._class = nwItemClass.NOVEL
itemA._expanded = True itemA._expanded = True
itemB = NWItem(theProject) itemB = NWItem(theProject, "b000000000001")
itemB._name = "Act One" itemB._name = "Act One"
itemB._parent = "a000000000001"
itemB._type = nwItemType.FOLDER itemB._type = nwItemType.FOLDER
itemB._class = nwItemClass.NOVEL itemB._class = nwItemClass.NOVEL
itemB._expanded = True itemB._expanded = True
itemC = NWItem(theProject) itemC = NWItem(theProject, "c000000000001")
itemC._name = "Chapter One" itemC._name = "Chapter One"
itemC._parent = "b000000000001"
itemC._type = nwItemType.FILE itemC._type = nwItemType.FILE
itemC._class = nwItemClass.NOVEL itemC._class = nwItemClass.NOVEL
itemC._layout = nwItemLayout.DOCUMENT itemC._layout = nwItemLayout.DOCUMENT
@@ -60,8 +65,9 @@ def mockItems(mockGUI, mockRnd):
itemC._wordCount = 50 itemC._wordCount = 50
itemC._paraCount = 2 itemC._paraCount = 2
itemD = NWItem(theProject) itemD = NWItem(theProject, "c000000000002")
itemD._name = "Scene One" itemD._name = "Scene One"
itemD._parent = "b000000000001"
itemD._type = nwItemType.FILE itemD._type = nwItemType.FILE
itemD._class = nwItemClass.NOVEL itemD._class = nwItemClass.NOVEL
itemD._layout = nwItemLayout.DOCUMENT itemD._layout = nwItemLayout.DOCUMENT
@@ -69,26 +75,30 @@ def mockItems(mockGUI, mockRnd):
itemD._wordCount = 500 itemD._wordCount = 500
itemD._paraCount = 20 itemD._paraCount = 20
itemE = NWItem(theProject) itemE = NWItem(theProject, "a000000000002")
itemE._name = "Outtakes" itemE._name = "Outtakes"
itemE._parent = None
itemE._type = nwItemType.ROOT itemE._type = nwItemType.ROOT
itemE._class = nwItemClass.ARCHIVE itemE._class = nwItemClass.ARCHIVE
itemE._expanded = False itemE._expanded = False
itemF = NWItem(theProject) itemF = NWItem(theProject, "a000000000003")
itemF._name = "Trash" itemF._name = "Trash"
itemF._parent = None
itemF._type = nwItemType.ROOT itemF._type = nwItemType.ROOT
itemF._class = nwItemClass.TRASH itemF._class = nwItemClass.TRASH
itemF._expanded = False itemF._expanded = False
itemG = NWItem(theProject) itemG = NWItem(theProject, "a000000000004")
itemG._name = "Characters" itemG._name = "Characters"
itemG._parent = None
itemG._type = nwItemType.ROOT itemG._type = nwItemType.ROOT
itemG._class = nwItemClass.CHARACTER itemG._class = nwItemClass.CHARACTER
itemG._expanded = True itemG._expanded = True
itemH = NWItem(theProject) itemH = NWItem(theProject, "b000000000002")
itemH._name = "Jane Doe" itemH._name = "Jane Doe"
itemH._parent = "a000000000004"
itemH._type = nwItemType.FILE itemH._type = nwItemType.FILE
itemH._class = nwItemClass.CHARACTER itemH._class = nwItemClass.CHARACTER
itemH._layout = nwItemLayout.NOTE itemH._layout = nwItemLayout.NOTE
@@ -96,18 +106,7 @@ def mockItems(mockGUI, mockRnd):
itemH._wordCount = 400 itemH._wordCount = 400
itemH._paraCount = 16 itemH._paraCount = 16
theItems = [ return [itemA, itemB, itemC, itemD, itemE, itemF, itemG, itemH]
("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
@pytest.mark.core @pytest.mark.core
@@ -123,10 +122,10 @@ def testCoreTree_BuildTree(mockGUI, mockItems):
assert theTree.trashRoot() is None assert theTree.trashRoot() is None
aHandles = [] aHandles = []
for tHandle, pHandle, nwItem in mockItems: for nwItem in mockItems:
aHandles.append(tHandle) aHandles.append(nwItem.itemHandle)
assert theTree.append(tHandle, pHandle, nwItem) is True assert theTree.append(nwItem) is True
assert theTree.updateItemData(tHandle) is True assert theTree.updateItemData(nwItem.itemHandle) is True
assert theTree._treeChanged is True assert theTree._treeChanged is True
@@ -143,6 +142,9 @@ def testCoreTree_BuildTree(mockGUI, mockItems):
for theItem, theHandle in zip(theTree, aHandles): for theItem, theHandle in zip(theTree, aHandles):
assert theItem.itemHandle == theHandle assert theItem.itemHandle == theHandle
# Trash Folder
# ============
# Check that we have the correct archive and trash folders # Check that we have the correct archive and trash folders
assert theTree.trashRoot() == "a000000000003" assert theTree.trashRoot() == "a000000000003"
assert theTree.findRoot(nwItemClass.ARCHIVE) == "a000000000002" 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("0000000000000") is True # Doesn't exist
assert theTree.isTrash("a000000000003") is True # This the trash folder 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 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 assert theTree.isTrash("b000000000002") is False # This is not trash
value = theTree["b000000000002"].itemParent value = theTree["b000000000002"].itemParent # type: ignore
theTree["b000000000002"].setParent("a000000000003") theTree["b000000000002"].setParent("a000000000003") # type: ignore
assert theTree.isTrash("b000000000002") is True # This is in trash assert theTree.isTrash("b000000000002") is True # This is in trash
theTree["b000000000002"].setParent(value) theTree["b000000000002"].setParent(value) # type: ignore
value = theTree["b000000000002"].itemRoot value = theTree["b000000000002"].itemRoot # type: ignore
theTree["b000000000002"].setRoot("a000000000003") theTree["b000000000002"].setRoot("a000000000003") # type: ignore
assert theTree.isTrash("b000000000002") is True # This is in trash 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 # Try to add another trash folder
itemT = NWItem(theProject) itemT = NWItem(theProject, "1111111111111")
itemT._name = "Trash" itemT._name = "Trash"
itemT._type = nwItemType.ROOT itemT._type = nwItemType.ROOT
itemT._class = nwItemClass.TRASH itemT._class = nwItemClass.TRASH
itemT._expanded = False itemT._expanded = False
assert theTree.append("1234567890abc", None, itemT) is False assert theTree.append(itemT) is False
assert len(theTree) == len(mockItems) assert len(theTree) == len(mockItems)
# Generate handle automatically # Create or Add Items
itemT = NWItem(theProject) # ===================
itemT._name = "New File"
itemT._type = nwItemType.FILE
itemT._class = nwItemClass.NOVEL
itemT._layout = nwItemLayout.DOCUMENT
assert theTree.append(None, None, itemT) is True # Create a new item, but with invalid parent
assert theTree.updateItemData(itemT.itemHandle) is True assert theTree.create("New File", "blabla", nwItemType.FILE, nwItemClass.NO_CLASS) is None
assert len(theTree) == len(mockItems) + 1
# 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() theList = theTree.handles()
nHandle = "0000000000000"
assert theList[-1] == nHandle assert theList[-1] == nHandle
# Try to add existing handle # Retrieve the item
assert theTree.append(nHandle, None, itemT) is False itemT = theTree[nHandle]
assert isinstance(itemT, NWItem)
assert len(theTree) == len(mockItems) + 1 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 # Delete a non-existing item
del theTree["stuff"] 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[nHandle]
del theTree[dHandle]
assert len(theTree) == len(mockItems) assert len(theTree) == len(mockItems)
assert nHandle not in theTree assert nHandle not in theTree
@@ -235,17 +273,17 @@ def testCoreTree_PackUnpack(mockGUI, mockItems):
theTree = NWTree(theProject) theTree = NWTree(theProject)
aHandles = [] aHandles = []
for tHandle, pHandle, nwItem in mockItems: for nwItem in mockItems:
aHandles.append(tHandle) aHandles.append(nwItem.itemHandle)
theTree.append(tHandle, pHandle, nwItem) theTree.append(nwItem)
theTree.updateItemData(tHandle) theTree.updateItemData(nwItem.itemHandle)
assert len(theTree) == len(mockItems) assert len(theTree) == len(mockItems)
# Pack # Pack
tree = theTree.pack() tree = theTree.pack()
for i, (tHandle, pHandle, nwItem) in enumerate(mockItems): for i, nwItem in enumerate(mockItems):
assert tree[i]["itemAttr"]["handle"] == tHandle assert tree[i]["itemAttr"]["handle"] == nwItem.itemHandle
# Unpack # Unpack
theTree.clear() theTree.clear()
@@ -257,15 +295,82 @@ def testCoreTree_PackUnpack(mockGUI, mockItems):
# END Test testCoreTree_PackUnpack # 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 @pytest.mark.core
def testCoreTree_Methods(mockGUI, mockItems): def testCoreTree_Methods(mockGUI, mockItems):
"""Test various class methods.""" """Test various class methods."""
theProject = NWProject(mockGUI) theProject = NWProject(mockGUI)
theTree = NWTree(theProject) theTree = NWTree(theProject)
for tHandle, pHandle, nwItem in mockItems: for nwItem in mockItems:
theTree.append(tHandle, pHandle, nwItem) theTree.append(nwItem)
theTree.updateItemData(tHandle) theTree.updateItemData(nwItem.itemHandle)
assert len(theTree) == len(mockItems) assert len(theTree) == len(mockItems)
@@ -273,22 +378,22 @@ def testCoreTree_Methods(mockGUI, mockItems):
assert theTree.updateItemData("stuff") is False assert theTree.updateItemData("stuff") is False
# Update item data, invalid item parent # Update item data, invalid item parent
corrParent = theTree["b000000000001"].itemParent corrParent = theTree["b000000000001"].itemParent # type: ignore
theTree["b000000000001"].setParent("0000000000000") theTree["b000000000001"].setParent("0000000000000") # type: ignore
assert theTree.updateItemData("b000000000001") is False assert theTree.updateItemData("b000000000001") is False
# Update item data, valid item parent # Update item data, valid item parent
theTree["b000000000001"].setParent(corrParent) theTree["b000000000001"].setParent(corrParent) # type: ignore
assert theTree.updateItemData("b000000000001") is True assert theTree.updateItemData("b000000000001") is True
# Update item data, root is unreachable # Update item data, root is unreachable
maxDepth = theTree.MAX_DEPTH maxDepth = theTree.MAX_DEPTH
theTree.MAX_DEPTH = 0 theTree.MAX_DEPTH = 0 # type: ignore
with pytest.raises(RecursionError): with pytest.raises(RecursionError):
theTree.updateItemData("b000000000001") theTree.updateItemData("b000000000001")
theTree.MAX_DEPTH = maxDepth theTree.MAX_DEPTH = maxDepth
# Chech type # Check type
assert theTree.checkType("blabla", nwItemType.FILE) is False assert theTree.checkType("blabla", nwItemType.FILE) is False
assert theTree.checkType("b000000000001", nwItemType.FILE) is False assert theTree.checkType("b000000000001", nwItemType.FILE) is False
assert theTree.checkType("c000000000001", nwItemType.FILE) is True assert theTree.checkType("c000000000001", nwItemType.FILE) is True
@@ -306,7 +411,7 @@ def testCoreTree_Methods(mockGUI, mockItems):
assert roots[3][0] == "a000000000004" assert roots[3][0] == "a000000000004"
# Add a fake item to root and check that it can handle it # 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 assert theTree.findRoot(nwItemClass.WORLD) is None
del theTree._treeRoots["0000000000000"] del theTree._treeRoots["0000000000000"]
@@ -318,18 +423,18 @@ def testCoreTree_Methods(mockGUI, mockItems):
# Cause recursion error # Cause recursion error
maxDepth = theTree.MAX_DEPTH maxDepth = theTree.MAX_DEPTH
theTree.MAX_DEPTH = 0 theTree.MAX_DEPTH = 0 # type: ignore
with pytest.raises(RecursionError): with pytest.raises(RecursionError):
theTree.getItemPath("c000000000001") theTree.getItemPath("c000000000001")
theTree.MAX_DEPTH = maxDepth theTree.MAX_DEPTH = maxDepth
# Break the folder parent handle # Break the folder parent handle
theTree["b000000000001"]._parent = "stuff" theTree["b000000000001"]._parent = "stuff" # type: ignore
assert theTree.getItemPath("c000000000001") == [ assert theTree.getItemPath("c000000000001") == [
"c000000000001", "b000000000001" "c000000000001", "b000000000001"
] ]
theTree["b000000000001"]._parent = "a000000000001" theTree["b000000000001"]._parent = "a000000000001" # type: ignore
assert theTree.getItemPath("c000000000001") == [ assert theTree.getItemPath("c000000000001") == [
"c000000000001", "b000000000001", "a000000000001" "c000000000001", "b000000000001", "a000000000001"
] ]
@@ -349,13 +454,13 @@ def testCoreTree_MakeHandles(mockGUI):
random.seed(42) random.seed(42)
tHandle = theTree._makeHandle() tHandle = theTree._makeHandle()
assert tHandle == handles[0] 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 # 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() tHandle = theTree._makeHandle()
assert tHandle == handles[2] 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 # Reset the seed to force collissions, which should still end up
# returning the next handle in the sequence # returning the next handle in the sequence
@@ -372,8 +477,8 @@ def testCoreTree_Stats(mockGUI, mockItems):
theProject = NWProject(mockGUI) theProject = NWProject(mockGUI)
theTree = NWTree(theProject) theTree = NWTree(theProject)
for tHandle, pHandle, nwItem in mockItems: for nwItem in mockItems:
theTree.append(tHandle, pHandle, nwItem) theTree.append(nwItem)
assert len(theTree) == len(mockItems) assert len(theTree) == len(mockItems)
theTree._treeOrder.append("stuff") theTree._treeOrder.append("stuff")
@@ -393,9 +498,9 @@ def testCoreTree_Reorder(caplog, mockGUI, mockItems):
theTree = NWTree(theProject) theTree = NWTree(theProject)
aHandle = [] aHandle = []
for tHandle, pHandle, nwItem in mockItems: for nwItem in mockItems:
aHandle.append(tHandle) aHandle.append(nwItem.itemHandle)
theTree.append(tHandle, pHandle, nwItem) theTree.append(nwItem)
assert len(theTree) == len(mockItems) assert len(theTree) == len(mockItems)
@@ -422,14 +527,14 @@ def testCoreTree_Reorder(caplog, mockGUI, mockItems):
@pytest.mark.core @pytest.mark.core
def testCoreTree_ToCFile(monkeypatch, tstPaths, mockGUI, mockItems): def testCoreTree_ToCFile(monkeypatch, fncPath, mockGUI, mockItems):
"""Test writing the ToC.txt file.""" """Test writing the ToC.txt file."""
theProject = NWProject(mockGUI) theProject = NWProject(mockGUI)
theTree = NWTree(theProject) theTree = NWTree(theProject)
for tHandle, pHandle, nwItem in mockItems: for nwItem in mockItems:
theTree.append(tHandle, pHandle, nwItem) theTree.append(nwItem)
theTree.updateItemData(tHandle) theTree.updateItemData(nwItem.itemHandle)
assert len(theTree) == len(mockItems) assert len(theTree) == len(mockItems)
theTree._treeOrder.append("stuff") theTree._treeOrder.append("stuff")
@@ -443,24 +548,27 @@ def testCoreTree_ToCFile(monkeypatch, tstPaths, mockGUI, mockItems):
return dItem.itemType == nwItemType.FILE return dItem.itemType == nwItemType.FILE
monkeypatch.setattr("pathlib.Path.is_file", mockIsFile) monkeypatch.setattr("pathlib.Path.is_file", mockIsFile)
theProject._storage._runtimePath = fncPath
(fncPath / "content").mkdir()
theProject._storage._runtimePath = None # Block extraction of the path
assert theTree.writeToCFile() is False 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: with monkeypatch.context() as mp:
mp.setattr("builtins.open", causeOSError) mp.setattr("builtins.open", causeOSError)
assert theTree.writeToCFile() is False assert theTree.writeToCFile() is False
theProject._storage._runtimePath = tstPaths.tmpDir # Allow writing
(tstPaths.tmpDir / "content").mkdir()
assert theTree.writeToCFile() is True assert theTree.writeToCFile() is True
pathA = str(Path("content") / "c000000000001.nwd") pathA = str(Path("content") / "c000000000001.nwd")
pathB = str(Path("content") / "c000000000002.nwd") pathB = str(Path("content") / "c000000000002.nwd")
pathC = str(Path("content") / "b000000000002.nwd") pathC = str(Path("content") / "b000000000002.nwd")
assert readFile(tstPaths.tmpDir / nwFiles.TOC_TXT) == ( assert (fncPath / nwFiles.TOC_TXT).read_text() == (
"\n" "\n"
"Table of Contents\n" "Table of Contents\n"
"=================\n" "=================\n"