Improve project item and tree classes (#1482)
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -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
@@ -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:
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
])
|
])
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user