diff --git a/novelwriter/core/buildsettings.py b/novelwriter/core/buildsettings.py index 524a1714..a5c5cd56 100644 --- a/novelwriter/core/buildsettings.py +++ b/novelwriter/core/buildsettings.py @@ -353,8 +353,6 @@ class BuildSettings: for item in project.tree: tHandle = item.itemHandle - if tHandle is None: - continue if item.isInactiveClass() or (item.itemRoot in self._skipRoot): result[tHandle] = (False, FilterMode.SKIPPED) continue diff --git a/novelwriter/core/coretools.py b/novelwriter/core/coretools.py index 03dda141..e7791613 100644 --- a/novelwriter/core/coretools.py +++ b/novelwriter/core/coretools.py @@ -287,7 +287,7 @@ class DocDuplicator: hMap: dict[str, str | None] = {t: None for t in items} for tHandle in items: newItem = self._project.tree.duplicate(tHandle) - if newItem is None or newItem.itemHandle is None: + if newItem is None: return hMap[tHandle] = newItem.itemHandle if newItem.itemParent in hMap: diff --git a/novelwriter/core/docbuild.py b/novelwriter/core/docbuild.py index e960debc..c3301e0f 100644 --- a/novelwriter/core/docbuild.py +++ b/novelwriter/core/docbuild.py @@ -101,8 +101,6 @@ class NWBuildDocument: self._queue = [] filtered = self._build.buildItemFilter(self._project) for item in self._project.tree: - if not item.itemHandle: - continue if filtered.get(item.itemHandle, False): self._queue.append(item.itemHandle) return diff --git a/novelwriter/core/document.py b/novelwriter/core/document.py index 8e4660a8..35844a2f 100644 --- a/novelwriter/core/document.py +++ b/novelwriter/core/document.py @@ -243,7 +243,7 @@ class NWDocument: """Return a pointer to the currently open NWItem.""" return self._theItem - def getMeta(self) -> tuple[str, str | None, str | None, str | None]: + def getMeta(self) -> tuple[str, str | None, nwItemClass | None, nwItemLayout | None]: """Parse the document meta tag and return the name, parent, class and layout meta values. """ diff --git a/novelwriter/core/item.py b/novelwriter/core/item.py index 21383d18..943e7c7b 100644 --- a/novelwriter/core/item.py +++ b/novelwriter/core/item.py @@ -1,7 +1,6 @@ """ novelWriter – Project Item Class ================================ -Data class for a project tree item File History: Created: 2018-10-27 [0.0.1] @@ -43,6 +42,13 @@ logger = logging.getLogger(__name__) class NWItem: + """Core: Item Data Class + + This class holds all the project information about a project item. + Each item must be associated with a project and have a valid handle. + Only the NWTree class should create instances of this class, and + must ensure that the handle is valid for all items in the tree. + """ __slots__ = ( "_project", "_name", "_handle", "_parent", "_root", "_order", @@ -51,11 +57,11 @@ class NWItem: "_paraCount", "_cursorPos", "_initCount", ) - def __init__(self, project: NWProject) -> None: + def __init__(self, project: NWProject, handle: str) -> None: self._project = project self._name = "" - self._handle = None + self._handle = handle self._parent = None self._root = None self._order = 0 @@ -81,31 +87,12 @@ class NWItem: return f"" def __bool__(self) -> bool: - """Evaluate to False if itemHandle is not set.""" - return self._handle is not None - - def __copy__(self) -> NWItem: - """Make a shallow copy of the current item.""" - item = NWItem(self._project) - item._name = self._name - item._handle = self._handle - item._parent = self._parent - item._root = self._root - item._order = self._order - item._type = self._type - item._class = self._class - item._layout = self._layout - item._status = self._status - item._import = self._import - item._active = self._active - item._expanded = self._expanded - item._heading = self._heading - item._charCount = self._charCount - item._wordCount = self._wordCount - item._paraCount = self._paraCount - item._cursorPos = self._cursorPos - item._initCount = self._initCount - return item + """The truthiness of the class. The handle used to be initiated + to None, but this is no longer the case. It should always + evaluate to True since 2.1-beta1, although unpack and the NWTree + class can leave it as an empty string. + """ + return bool(self._handle) ## # Properties @@ -116,7 +103,7 @@ class NWItem: return self._name @property - def itemHandle(self) -> str | None: + def itemHandle(self) -> str: return self._handle @property @@ -184,7 +171,7 @@ class NWItem: return self._cursorPos ## - # Pack/Unpack Data + # Pack/Unpack/Duplicate Data ## def pack(self) -> dict: @@ -227,8 +214,9 @@ class NWItem: meta = data.get("metaAttr", {}) name = data.get("nameAttr", {}) - if "handle" in item: - self.setHandle(item["handle"]) + handle = item.get("handle", "") + if isHandle(handle): + self._handle = handle else: logger.error("Item does not have a handle") return False @@ -269,6 +257,29 @@ class NWItem: return True + @classmethod + def duplicate(cls, source: NWItem, handle: str) -> NWItem: + """Make a copy of an item.""" + cls = NWItem(source._project, handle) + cls._name = source._name + cls._parent = source._parent + cls._root = source._root + cls._order = source._order + cls._type = source._type + cls._class = source._class + cls._layout = source._layout + cls._status = source._status + cls._import = source._import + cls._active = source._active + cls._expanded = source._expanded + cls._heading = source._heading + cls._charCount = source._charCount + cls._wordCount = source._wordCount + cls._paraCount = source._paraCount + cls._cursorPos = source._cursorPos + cls._initCount = source._initCount + return cls + ## # Lookup Methods ## @@ -387,14 +398,6 @@ class NWItem: self._name = "" return - def setHandle(self, handle: Any) -> None: - """Set the item handle, and ensure it is valid.""" - if isHandle(handle): - self._handle = handle - else: - self._handle = None - return - def setParent(self, handle: Any) -> None: """Set the parent handle, and ensure it is valid.""" if handle is None: diff --git a/novelwriter/core/project.py b/novelwriter/core/project.py index 37b46423..584d762f 100644 --- a/novelwriter/core/project.py +++ b/novelwriter/core/project.py @@ -38,7 +38,6 @@ from novelwriter.enum import nwItemType, nwItemClass, nwItemLayout, nwAlert from novelwriter.error import logException from novelwriter.constants import trConst, nwLabels from novelwriter.core.tree import NWTree -from novelwriter.core.item import NWItem from novelwriter.core.index import NWIndex from novelwriter.core.options import OptionState from novelwriter.core.storage import NWStorage @@ -130,42 +129,20 @@ class NWProject(QObject): # Item Methods ## - def newRoot(self, itemClass, label=None): - """Add a new root item. If label is None, use the class label. + def newRoot(self, itemClass: nwItemClass, label: str | None = None) -> str: + """Add a new root folder to the project. If label is not set, + use the class label. """ - if label is None: - label = trConst(nwLabels.CLASS_NAME[itemClass]) - newItem = NWItem(self) - newItem.setName(label) - newItem.setType(nwItemType.ROOT) - newItem.setClass(itemClass) - self._tree.append(None, None, newItem) - self._tree.updateItemData(newItem.itemHandle) - return newItem.itemHandle + label = label or trConst(nwLabels.CLASS_NAME[itemClass]) + return self._tree.create(label, None, nwItemType.ROOT, itemClass) - def newFolder(self, label, pHandle): - """Add a new folder with a given label and parent item. - """ - if pHandle not in self._tree: - return None - newItem = NWItem(self) - newItem.setName(label) - newItem.setType(nwItemType.FOLDER) - self._tree.append(None, pHandle, newItem) - self._tree.updateItemData(newItem.itemHandle) - return newItem.itemHandle + def newFolder(self, label: str, parent: str) -> str | None: + """Add a new folder with a given label and parent item.""" + return self._tree.create(label, parent, nwItemType.FOLDER) - def newFile(self, label, pHandle): - """Add a new file with a given label and parent item. - """ - if pHandle not in self._tree: - return None - newItem = NWItem(self) - newItem.setName(label) - newItem.setType(nwItemType.FILE) - self._tree.append(None, pHandle, newItem) - self._tree.updateItemData(newItem.itemHandle) - return newItem.itemHandle + def newFile(self, label: str, parent: str) -> str | None: + """Add a new file with a given label and parent item.""" + return self._tree.create(label, parent, nwItemType.FILE) def writeNewFile(self, tHandle, hLevel, isDocument, addText=""): """Write content to a new document after it is created. This @@ -211,18 +188,11 @@ class NWProject(QObject): return True def trashFolder(self): - """Add the special trash root folder to the project. - """ + """Add the special trash root folder to the project.""" trashHandle = self._tree.trashRoot() if trashHandle is None: - newItem = NWItem(self) - newItem.setName(trConst(nwLabels.CLASS_NAME[nwItemClass.TRASH])) - newItem.setType(nwItemType.ROOT) - newItem.setClass(nwItemClass.TRASH) - self._tree.append(None, None, newItem) - self._tree.updateItemData(newItem.itemHandle) - return newItem.itemHandle - + label = trConst(nwLabels.CLASS_NAME[nwItemClass.TRASH]) + return self._tree.create(label, None, nwItemType.ROOT, nwItemClass.TRASH) return trashHandle ## @@ -769,11 +739,8 @@ class NWProject(QObject): 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 + oClass = oClass or nwItemClass.NOVEL + oLayout = oLayout or nwItemLayout.NOTE if oParent is None or oParent not in self._tree: oParent = self._tree.findRoot(oClass) @@ -785,13 +752,9 @@ class NWProject(QObject): 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) + nHandle = self._tree.create(oName, oParent, nwItemType.FILE, oClass, oLayout) + if nHandle is not None: + (contentPath / f"{oHandle}.nwd").rename(contentPath / f"{nHandle}.nwd") if noWhere: self.mainGui.makeAlert(self.tr( diff --git a/novelwriter/core/tree.py b/novelwriter/core/tree.py index b163869a..8ee9040b 100644 --- a/novelwriter/core/tree.py +++ b/novelwriter/core/tree.py @@ -1,7 +1,6 @@ """ novelWriter – Project Tree Class ================================ -Data class for the project's tree of project items File History: Created: 2020-05-07 [0.4.5] @@ -24,16 +23,15 @@ along with this program. If not, see . """ from __future__ import annotations -import copy import random import logging -from typing import TYPE_CHECKING, Iterator +from typing import TYPE_CHECKING, Iterator, overload from pathlib import Path from novelwriter.enum import nwItemClass, nwItemLayout, nwItemType from novelwriter.error import logException -from novelwriter.common import checkHandle +from novelwriter.common import isHandle from novelwriter.constants import nwFiles from novelwriter.core.item import NWItem @@ -44,6 +42,22 @@ logger = logging.getLogger(__name__) class NWTree: + """Core: Project Tree Data Class + + Only one instance of this class should exist in the project class. + This class holds all the project items of the project as instances + of NWItem. + + For historical reasons, the order of the items is saved in a + separate list from the items themselves, which are stored in a + dictionary. This is somewhat redundant with the newer versions of + Python, but is still practical as it's easier to update the item + order as a list. + + Each item has a handle, which is a random hex string of length 13. + The handle is the name of the item everywhere in novelWriter, and is + also used for file names. + """ MAX_DEPTH = 1000 # Cap of tree traversing for loops @@ -52,7 +66,7 @@ class NWTree: self._project = project self._projTree: dict[str, NWItem] = {} # Holds all the items of the project - self._treeOrder: list[str] = [] # The order of the tree items on the tree view + self._treeOrder: list[str] = [] # The order of the tree items in the tree view self._treeRoots: dict[str, NWItem] = {} # The root items of the tree self._trashRoot = None # The handle of the trash root folder @@ -79,12 +93,44 @@ class NWTree: """Returns a copy of the list of all the active handles.""" return self._treeOrder.copy() - def append(self, tHandle: str | None, pHandle: str | None, nwItem: NWItem) -> bool: - """Add a new item to the end of the tree.""" - tHandle = checkHandle(tHandle, None, True) - pHandle = checkHandle(pHandle, None, True) - if tHandle is None: + @overload + def create(self, label: str, parent: None, itemType: nwItemType, + itemClass: nwItemClass = nwItemClass.NO_CLASS, + itemLayout: nwItemLayout = nwItemLayout.NO_LAYOUT) -> str: + ... + + @overload + def create(self, label: str, parent: str | None, itemType: nwItemType, + itemClass: nwItemClass = nwItemClass.NO_CLASS, + itemLayout: nwItemLayout = nwItemLayout.NO_LAYOUT) -> str | None: + ... + + def create(self, label, parent, itemType, + itemClass=nwItemClass.NO_CLASS, itemLayout=nwItemLayout.NO_LAYOUT): + """Create a new item in the project tree, and return its handle. + If the item cannot be added to the project, None is returned. + """ + if parent is None or parent in self._treeOrder: tHandle = self._makeHandle() + newItem = NWItem(self._project, tHandle) + newItem.setName(label) + newItem.setParent(parent) + newItem.setType(itemType) + newItem.setClass(itemClass) + newItem.setLayout(itemLayout) + self.append(newItem) + self.updateItemData(tHandle) + return tHandle + return None + + def append(self, nwItem: NWItem) -> bool: + """Add a new item to the end of the tree.""" + tHandle = nwItem.itemHandle + pHandle = nwItem.itemParent + + if not isHandle(tHandle): + logger.warning("Invalid item handle '%s' detected, skipping", tHandle) + return False if tHandle in self._projTree: logger.warning("Duplicate handle '%s' detected, skipping", tHandle) @@ -92,9 +138,6 @@ class NWTree: logger.debug("Adding item '%s' with parent '%s'", str(tHandle), str(pHandle)) - nwItem.setHandle(tHandle) - nwItem.setParent(pHandle) - if nwItem.isRootType(): logger.debug("Item '%s' is a root item", str(tHandle)) self._treeRoots[tHandle] = nwItem @@ -119,8 +162,8 @@ class NWTree: """Duplicate an item and set a new handle.""" sItem = self.__getitem__(sHandle) if isinstance(sItem, NWItem): - nItem = copy.copy(sItem) - if self.append(None, sItem.itemParent, nItem): + nItem = NWItem.duplicate(sItem, self._makeHandle()) + if self.append(nItem): logger.info("Duplicated item '%s' -> '%s'", sHandle, nItem.itemHandle) return nItem return None @@ -142,9 +185,9 @@ class NWTree: """ self.clear() for item in data: - nwItem = NWItem(self._project) + nwItem = NWItem(self._project, "NOTSET") # Handle is set by unpack() if nwItem.unpack(item): - self.append(nwItem.itemHandle, nwItem.itemParent, nwItem) + self.append(nwItem) nwItem.saveInitialCount() return @@ -314,7 +357,7 @@ class NWTree: return self._trashRoot return None - def findRoot(self, itemClass: nwItemClass) -> str | None: + def findRoot(self, itemClass: nwItemClass | None) -> str | None: """Find the first root item for a given class.""" for aRoot in self._treeRoots: tItem = self.__getitem__(aRoot)