Add duplication of files and folders (#1480)

This commit is contained in:
Veronica Berglyd Olsen
2023-07-20 21:18:17 +02:00
committed by GitHub
11 changed files with 816 additions and 378 deletions
+96 -49
View File
@@ -23,10 +23,12 @@ General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
"""
from __future__ import annotations
import shutil
import logging
from typing import TYPE_CHECKING, Iterable
from functools import partial
from PyQt5.QtCore import QCoreApplication
@@ -35,8 +37,12 @@ from novelwriter import CONFIG
from novelwriter.enum import nwAlert
from novelwriter.common import minmax, simplified
from novelwriter.constants import nwItemClass
from novelwriter.core.item import NWItem
from novelwriter.core.project import NWProject
if TYPE_CHECKING: # pragma: no cover
from novelwriter.guimain import GuiMain
logger = logging.getLogger(__name__)
@@ -46,26 +52,22 @@ class DocMerger:
GuiDocMerge dialog.
"""
def __init__(self, theProject):
self.theProject = theProject
def __init__(self, project: NWProject) -> None:
self._project = project
self._error = ""
self._targetDoc = None
self._targetText = []
return
##
# Methods
##
def getError(self):
"""Return any collected errors.
"""
def getError(self) -> str:
"""Return any collected errors."""
return self._error
def setTargetDoc(self, tHandle):
def setTargetDoc(self, tHandle: str) -> None:
"""Set the target document for the merging. Calling this
function resets the class.
"""
@@ -73,33 +75,33 @@ class DocMerger:
self._targetText = []
return
def newTargetDoc(self, srcHandle, docLabel):
"""Create a barnd new target document based on a source handle
def newTargetDoc(self, srcHandle: str, docLabel: str) -> str | None:
"""Create a brand new target document based on a source handle
and a new doc label. Calling this function resets the class.
"""
srcItem = self.theProject.tree[srcHandle]
srcItem = self._project.tree[srcHandle]
if srcItem is None:
return None
newHandle = self.theProject.newFile(docLabel, srcItem.itemParent)
newItem = self.theProject.tree[newHandle]
newItem.setLayout(srcItem.itemLayout)
newItem.setStatus(srcItem.itemStatus)
newItem.setImport(srcItem.itemImport)
newHandle = self._project.newFile(docLabel, srcItem.itemParent)
newItem = self._project.tree[newHandle]
if isinstance(newItem, NWItem):
newItem.setLayout(srcItem.itemLayout)
newItem.setStatus(srcItem.itemStatus)
newItem.setImport(srcItem.itemImport)
self._targetDoc = newHandle
self._targetText = []
return newHandle
def appendText(self, srcHandle, addComment, cmtPrefix):
"""Append text from an existing document to the text buffer.
"""
srcItem = self.theProject.tree[srcHandle]
def appendText(self, srcHandle: str, addComment: bool, cmtPrefix: str) -> bool:
"""Append text from an existing document to the text buffer."""
srcItem = self._project.tree[srcHandle]
if srcItem is None:
return False
inDoc = self.theProject.storage.getDocument(srcHandle)
inDoc = self._project.storage.getDocument(srcHandle)
docText = (inDoc.readDocument() or "").rstrip("\n")
if addComment:
@@ -112,14 +114,14 @@ class DocMerger:
return True
def writeTargetDoc(self):
def writeTargetDoc(self) -> bool:
"""Write the accumulated text into the designated target
document, appending any existing text.
"""
if self._targetDoc is None:
return False
outDoc = self.theProject.storage.getDocument(self._targetDoc)
outDoc = self._project.storage.getDocument(self._targetDoc)
docText = (outDoc.readDocument() or "").rstrip("\n")
if docText:
self._targetText.insert(0, docText)
@@ -139,9 +141,9 @@ class DocSplitter:
GuiDocSplit dialog.
"""
def __init__(self, theProject, sHandle):
def __init__(self, project: NWProject, sHandle: str) -> None:
self.theProject = theProject
self._project = project
self._error = ""
self._parHandle = None
@@ -151,7 +153,7 @@ class DocSplitter:
self._inFolder = False
self._rawData = []
srcItem = self.theProject.tree[sHandle]
srcItem = self._project.tree[sHandle]
if srcItem is not None and srcItem.isFileType():
self._srcHandle = sHandle
self._srcItem = srcItem
@@ -162,12 +164,11 @@ class DocSplitter:
# Methods
##
def getError(self):
"""Return any collected errors.
"""
def getError(self) -> str:
"""Return any collected errors."""
return self._error
def setParentItem(self, pHandle):
def setParentItem(self, pHandle: str) -> None:
"""Set the item that will be the top level parent item for the
new documents.
"""
@@ -175,25 +176,27 @@ class DocSplitter:
self._inFolder = False
return
def newParentFolder(self, pHandle, folderLabel):
def newParentFolder(self, pHandle: str, folderLabel: str) -> str | None:
"""Create a new folder that will be the top level parent item
for the new documents.
"""
if self._srcItem is None:
return None
newHandle = self.theProject.newFolder(folderLabel, pHandle)
newItem = self.theProject.tree[newHandle]
newItem.setStatus(self._srcItem.itemStatus)
newItem.setImport(self._srcItem.itemImport)
newHandle = self._project.newFolder(folderLabel, pHandle)
newItem = self._project.tree[newHandle]
if isinstance(newItem, NWItem):
newItem.setStatus(self._srcItem.itemStatus)
newItem.setImport(self._srcItem.itemImport)
self._parHandle = newHandle
self._inFolder = True
return newHandle
def splitDocument(self, splitData, splitText):
"""Loop through the split data record and perform the split job.
def splitDocument(self, splitData: list, splitText: list[str]) -> None:
"""Loop through the split data record and perform the split job
on a list of text lines.
"""
self._rawData = []
buffer = splitText.copy()
@@ -201,10 +204,9 @@ class DocSplitter:
chunk = buffer[lineNo:]
buffer = buffer[:lineNo]
self._rawData.insert(0, (chunk, hLevel, hLabel))
return
return True
def writeDocuments(self, docHierarchy):
def writeDocuments(self, docHierarchy: bool) -> Iterable[tuple[bool, str | None, str | None]]:
"""An iterator that will write each document in the buffer, and
return its new handle, parent handle, and sibling handle.
"""
@@ -237,14 +239,15 @@ class DocSplitter:
elif hLevel > pLevel:
nHandle = pHandle
dHandle = self.theProject.newFile(docLabel, pHandle)
dHandle = self._project.newFile(docLabel, pHandle)
hHandle[hLevel] = dHandle
newItem = self.theProject.tree[dHandle]
newItem.setStatus(self._srcItem.itemStatus)
newItem.setImport(self._srcItem.itemImport)
newItem = self._project.tree[dHandle]
if isinstance(newItem, NWItem):
newItem.setStatus(self._srcItem.itemStatus)
newItem.setImport(self._srcItem.itemImport)
outDoc = self.theProject.storage.getDocument(dHandle)
outDoc = self._project.storage.getDocument(dHandle)
status = outDoc.writeDocument("\n".join(docText))
if not status:
self._error = outDoc.getError()
@@ -260,12 +263,56 @@ class DocSplitter:
# END Class DocSplitter
class DocDuplicator:
"""A class that will duplicate all documents and folders starting
from a given handle.
"""
def __init__(self, project: NWProject) -> None:
self._project = project
return
##
# Methods
##
def duplicate(self, items: list[str]) -> Iterable[tuple[str, str | None]]:
"""Run through a list of items, duplicate them, and copy the
text content if they are documents.
"""
if not items:
return
nHandle = items[0]
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:
return
hMap[tHandle] = newItem.itemHandle
if newItem.itemParent in hMap:
newItem.setParent(hMap[newItem.itemParent])
self._project.tree.updateItemData(newItem.itemHandle)
if newItem.isFileType():
oldDoc = self._project.storage.getDocument(tHandle)
newDoc = self._project.storage.getDocument(newItem.itemHandle)
if newDoc.fileExists():
return
newDoc.writeDocument(oldDoc.readDocument() or "")
yield newItem.itemHandle, nHandle
nHandle = None
return
# END Class DocDuplicator
class ProjectBuilder:
"""A class to build a new project from a set of user-defined
parameter provided by the New Projecty Wizard.
"""
def __init__(self, mainGui):
def __init__(self, mainGui: GuiMain) -> None:
self.mainGui = mainGui
self.tr = partial(QCoreApplication.translate, "NWProject")
return
@@ -274,7 +321,7 @@ class ProjectBuilder:
# Methods
##
def buildProject(self, data):
def buildProject(self, data: dict) -> bool:
"""Build a project from a data dictionary of specifications
provided by the wizard.
"""
@@ -416,7 +463,7 @@ class ProjectBuilder:
# Internal Functions
##
def _extractSampleProject(self, data):
def _extractSampleProject(self, data: dict) -> bool:
"""Make a copy of the sample project by extracting the
sample.zip file to the new path.
"""
+41 -26
View File
@@ -22,23 +22,29 @@ General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
"""
from __future__ import annotations
import logging
from typing import TYPE_CHECKING
from pathlib import Path
from novelwriter.core.item import NWItem
from novelwriter.enum import nwItemLayout, nwItemClass
from novelwriter.error import formatException
from novelwriter.common import isHandle, sha256sum
if TYPE_CHECKING: # pragma: no cover
from novelwriter.core.project import NWProject
logger = logging.getLogger(__name__)
class NWDocument:
def __init__(self, theProject, theHandle):
def __init__(self, project: NWProject, tHandle: str) -> None:
self.theProject = theProject
self._project = project
# Internal Variables
self._theItem = None # The currently open item
@@ -49,25 +55,37 @@ class NWDocument:
self._prevHash = None # Previous sha256sum of the document file
self._currHash = None # Latest sha256sum of the document file
if isHandle(theHandle):
self._docHandle = theHandle
if isHandle(tHandle):
self._docHandle = tHandle
if self._docHandle is not None:
self._theItem = self.theProject.tree[theHandle]
self._theItem = self._project.tree[tHandle]
return
def __repr__(self):
def __repr__(self) -> str:
return f"<NWDocument handle={self._docHandle}>"
def __bool__(self):
def __bool__(self) -> bool:
return self._docHandle is not None and bool(self._theItem)
##
# Class Methods
##
def readDocument(self, isOrphan=False):
def fileExists(self) -> bool:
"""Check if the document file exists."""
if self._docHandle is None:
return False
contentPath = self._project.storage.contentPath
if not isinstance(contentPath, Path):
logger.error("No content path set")
return False
return (contentPath / f"{self._docHandle}.nwd").is_file()
def readDocument(self, isOrphan: bool = False) -> str | None:
"""Read the document specified by the handle set in the
contructor, capturing potential file system errors and parse
meta data. If the document doesn't exist on disk, return an
@@ -82,12 +100,12 @@ class NWDocument:
logger.error("Unknown novelWriter document")
return None
contentPath = self.theProject.storage.contentPath
contentPath = self._project.storage.contentPath
if not isinstance(contentPath, Path):
logger.error("No content path set")
return None
docFile = self._docHandle+".nwd"
docFile = f"{self._docHandle}.nwd"
logger.debug("Opening document: %s", docFile)
docPath = contentPath / docFile
@@ -125,7 +143,7 @@ class NWDocument:
return theText
def writeDocument(self, docText, forceWrite=False):
def writeDocument(self, docText: str, forceWrite: bool = False) -> bool:
"""Write the document specified by the handle attribute. Handle
any IO errors in the process Returns True if successful, False
if not.
@@ -135,12 +153,12 @@ class NWDocument:
logger.error("No document handle set")
return False
contentPath = self.theProject.storage.contentPath
contentPath = self._project.storage.contentPath
if not isinstance(contentPath, Path):
logger.error("No content path set")
return False
docFile = self._docHandle+".nwd"
docFile = f"{self._docHandle}.nwd"
logger.debug("Saving document: %s", docFile)
docPath = contentPath / docFile
@@ -183,7 +201,7 @@ class NWDocument:
return True
def deleteDocument(self):
def deleteDocument(self) -> bool:
"""Permanently delete a document source file and related files
from the project data folder.
"""
@@ -192,7 +210,7 @@ class NWDocument:
logger.error("No document handle set")
return False
contentPath = self.theProject.storage.contentPath
contentPath = self._project.storage.contentPath
if not isinstance(contentPath, Path):
logger.error("No content path set")
return False
@@ -217,17 +235,15 @@ class NWDocument:
# Getters
##
def getFileLocation(self):
"""Return the file location of the current document.
"""
def getFileLocation(self) -> str:
"""Return the file location of the current document."""
return str(self._fileLoc)
def getCurrentItem(self):
"""Return a pointer to the currently open NWItem.
"""
def getCurrentItem(self) -> NWItem | None:
"""Return a pointer to the currently open NWItem."""
return self._theItem
def getMeta(self):
def getMeta(self) -> tuple[str, str | None, str | None, str | None]:
"""Parse the document meta tag and return the name, parent,
class and layout meta values.
"""
@@ -238,16 +254,15 @@ class NWDocument:
return theName, theParent, theClass, theLayout
def getError(self):
"""Return the last recorded exception.
"""
def getError(self) -> str:
"""Return the last recorded exception."""
return self._docError
##
# Internal Functions
##
def _parseMeta(self, metaLine):
def _parseMeta(self, metaLine: str) -> None:
"""Parse a line from the document starting with the characters
%%~ that may contain meta data.
"""
+110 -101
View File
@@ -22,15 +22,23 @@ General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
"""
from __future__ import annotations
import logging
from typing import TYPE_CHECKING, Any
from PyQt5.QtGui import QIcon
from novelwriter.enum import nwItemType, nwItemClass, nwItemLayout
from novelwriter.common import (
checkInt, isHandle, isItemClass, isItemLayout, isItemType, simplified, yesNo
)
from novelwriter.constants import nwHeaders, nwLabels, trConst
if TYPE_CHECKING: # pragma: no cover
from novelwriter.core.project import NWProject
logger = logging.getLogger(__name__)
@@ -43,7 +51,7 @@ class NWItem:
"_paraCount", "_cursorPos", "_initCount",
)
def __init__(self, project):
def __init__(self, project: NWProject) -> None:
self._project = project
self._name = ""
@@ -69,98 +77,121 @@ class NWItem:
return
def __repr__(self):
def __repr__(self) -> str:
return f"<NWItem handle={self._handle}, parent={self._parent}, name='{self._name}'>"
def __bool__(self):
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
##
# Properties
##
@property
def itemName(self):
def itemName(self) -> str:
return self._name
@property
def itemHandle(self):
def itemHandle(self) -> str | None:
return self._handle
@property
def itemParent(self):
def itemParent(self) -> str | None:
return self._parent
@property
def itemRoot(self):
def itemRoot(self) -> str | None:
return self._root
@property
def itemOrder(self):
def itemOrder(self) -> int:
return self._order
@property
def itemType(self):
def itemType(self) -> nwItemType:
return self._type
@property
def itemClass(self):
def itemClass(self) -> nwItemClass:
return self._class
@property
def itemLayout(self):
def itemLayout(self) -> nwItemLayout:
return self._layout
@property
def itemStatus(self):
def itemStatus(self) -> str | None:
return self._status
@property
def itemImport(self):
def itemImport(self) -> str | None:
return self._import
@property
def isActive(self):
def isActive(self) -> bool:
return self._active
@property
def isExpanded(self):
def isExpanded(self) -> bool:
return self._expanded
@property
def mainHeading(self):
def mainHeading(self) -> str:
return self._heading
@property
def charCount(self):
def charCount(self) -> int:
return self._charCount
@property
def wordCount(self):
def wordCount(self) -> int:
return self._wordCount
@property
def paraCount(self):
def paraCount(self) -> int:
return self._paraCount
@property
def initCount(self):
def initCount(self) -> int:
return self._initCount
@property
def cursorPos(self):
def cursorPos(self) -> int:
return self._cursorPos
##
# Pack/Unpack Data
##
def pack(self):
"""Pack all the data in the class instance into a dictionary.
"""
item = {}
meta = {}
name = {}
def pack(self) -> dict:
"""Pack all the data in the class instance into a dictionary."""
item: dict[str, str] = {}
meta: dict[str, str] = {}
name: dict[str, str] = {}
item["handle"] = str(self._handle)
item["parent"] = str(self._parent)
@@ -190,9 +221,8 @@ class NWItem:
return data
def unpack(self, data):
"""Set the values from a data dictionary.
"""
def unpack(self, data: dict) -> bool:
"""Set the values from a data dictionary."""
item = data.get("itemAttr", {})
meta = data.get("metaAttr", {})
name = data.get("nameAttr", {})
@@ -243,9 +273,8 @@ class NWItem:
# Lookup Methods
##
def describeMe(self):
"""Return a string description of the item.
"""
def describeMe(self) -> str:
"""Return a string description of the item."""
descKey = "none"
if self._type == nwItemType.ROOT:
descKey = "root"
@@ -268,7 +297,7 @@ class NWItem:
return trConst(nwLabels.ITEM_DESCRIPTION.get(descKey, ""))
def getImportStatus(self, incIcon=True):
def getImportStatus(self, incIcon: bool = True) -> tuple[str, QIcon | None]:
"""Return the relevant importance or status label and icon for
the current item based on its class.
"""
@@ -284,51 +313,43 @@ class NWItem:
# Checker Methods
##
def isNovelLike(self):
"""Returns true if the item is of a novel-like class.
"""
def isNovelLike(self) -> bool:
"""Check if the item is of a novel-like class."""
return self._class in (nwItemClass.NOVEL, nwItemClass.ARCHIVE)
def documentAllowed(self):
"""Returns true if the item is allowed to be of document layout.
"""
def documentAllowed(self) -> bool:
"""Check if the item is allowed to be of document layout."""
return self._class in (nwItemClass.NOVEL, nwItemClass.ARCHIVE, nwItemClass.TRASH)
def isInactiveClass(self):
"""Returns true if the item is in an inactive class.
"""
def isInactiveClass(self) -> bool:
"""Check if the item is in an inactive class."""
return self._class in (nwItemClass.NO_CLASS, nwItemClass.ARCHIVE, nwItemClass.TRASH)
def isRootType(self):
def isRootType(self) -> bool:
"""Check if item is a root item."""
return self._type == nwItemType.ROOT
def isFolderType(self):
def isFolderType(self) -> bool:
"""Check if item is a folder item."""
return self._type == nwItemType.FOLDER
def isFileType(self):
def isFileType(self) -> bool:
"""Check if item is a file item."""
return self._type == nwItemType.FILE
def isNoteLayout(self):
def isNoteLayout(self) -> bool:
"""Check if item is a project note."""
return self._layout == nwItemLayout.NOTE
def isDocumentLayout(self):
def isDocumentLayout(self) -> bool:
"""Check if item is a novel document."""
return self._layout == nwItemLayout.DOCUMENT
##
# Special Setters
##
def setImportStatus(self, value):
"""Update the importance or status value based on class. This is
a wrapper setter for setStatus and setImport.
"""
if self.isNovelLike():
self.setStatus(value)
else:
self.setImport(value)
return
def setClassDefaults(self, itemClass):
def setClassDefaults(self, itemClass: nwItemClass) -> None:
"""Set the default values based on the item's class and the
project settings.
"""
@@ -358,27 +379,24 @@ class NWItem:
# Set Item Values
##
def setName(self, name):
"""Set the item name.
"""
def setName(self, name: Any) -> None:
"""Set the item name."""
if isinstance(name, str):
self._name = simplified(name)
else:
self._name = ""
return
def setHandle(self, handle):
"""Set the item handle, and ensure it is valid.
"""
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):
"""Set the parent handle, and ensure it is valid.
"""
def setParent(self, handle: Any) -> None:
"""Set the parent handle, and ensure it is valid."""
if handle is None:
self._parent = None
elif isHandle(handle):
@@ -387,9 +405,8 @@ class NWItem:
self._parent = None
return
def setRoot(self, handle):
"""Set the root handle, and ensure it is valid.
"""
def setRoot(self, handle: Any) -> None:
"""Set the root handle, and ensure it is valid."""
if handle is None:
self._root = None
elif isHandle(handle):
@@ -398,7 +415,7 @@ class NWItem:
self._root = None
return
def setOrder(self, order):
def setOrder(self, order: Any) -> None:
"""Set the item order, and ensure that it is valid. This value
is purely a meta value, and not actually used by novelWriter at
the moment.
@@ -406,7 +423,7 @@ class NWItem:
self._order = checkInt(order, 0)
return
def setType(self, value):
def setType(self, value: Any) -> None:
"""Set the item type from either a proper nwItemType, or set it
from a string representing an nwItemType.
"""
@@ -419,7 +436,7 @@ class NWItem:
self._type = nwItemType.NO_TYPE
return
def setClass(self, value):
def setClass(self, value: Any) -> None:
"""Set the item class from either a proper nwItemClass, or set
it from a string representing an nwItemClass.
"""
@@ -432,7 +449,7 @@ class NWItem:
self._class = nwItemClass.NO_CLASS
return
def setLayout(self, value):
def setLayout(self, value: Any) -> None:
"""Set the item layout from either a proper nwItemLayout, or set
it from a string representing an nwItemLayout.
"""
@@ -445,32 +462,30 @@ class NWItem:
self._layout = nwItemLayout.NO_LAYOUT
return
def setStatus(self, value):
def setStatus(self, value: Any) -> None:
"""Set the item status by looking it up in the valid status
items of the current project.
"""
self._status = self._project.data.itemStatus.check(value)
return
def setImport(self, value):
def setImport(self, value: Any) -> None:
"""Set the item importance by looking it up in the valid import
items of the current project.
"""
self._import = self._project.data.itemImport.check(value)
return
def setActive(self, state):
"""Set the active flag.
"""
def setActive(self, state: Any) -> None:
"""Set the active flag."""
if isinstance(state, bool):
self._active = state
else:
self._active = False
return
def setExpanded(self, state):
"""Set the expanded status of an item in the project tree.
"""
def setExpanded(self, state: Any) -> None:
"""Set the expanded status of an item in the project tree."""
if isinstance(state, bool):
self._expanded = state
else:
@@ -481,52 +496,46 @@ class NWItem:
# Set Document Meta Data
##
def setMainHeading(self, value):
"""Set the main heading level.
"""
def setMainHeading(self, value: str) -> None:
"""Set the main heading level."""
if value in nwHeaders.H_LEVEL:
self._heading = value
return
def setCharCount(self, count):
"""Set the character count, and ensure that it is an integer.
"""
def setCharCount(self, count: Any) -> None:
"""Set the character count, and ensure that it is an integer."""
if isinstance(count, int):
self._charCount = max(0, count)
else:
self._charCount = 0
return
def setWordCount(self, count):
"""Set the word count, and ensure that it is an integer.
"""
def setWordCount(self, count: Any) -> None:
"""Set the word count, and ensure that it is an integer."""
if isinstance(count, int):
self._wordCount = max(0, count)
else:
self._wordCount = 0
return
def setParaCount(self, count):
"""Set the paragraph count, and ensure that it is an integer.
"""
def setParaCount(self, count: Any) -> None:
"""Set the paragraph count, and ensure that it is an integer."""
if isinstance(count, int):
self._paraCount = max(0, count)
else:
self._paraCount = 0
return
def setCursorPos(self, position):
"""Set the cursor position, and ensure that it is an integer.
"""
def setCursorPos(self, position: Any) -> None:
"""Set the cursor position, and ensure that it is an integer."""
if isinstance(position, int):
self._cursorPos = max(0, position)
else:
self._cursorPos = 0
return
def saveInitialCount(self):
"""Save the initial word count.
"""
def saveInitialCount(self) -> None:
"""Save the initial word count."""
self._initCount = self._wordCount
return
+75 -95
View File
@@ -22,18 +22,24 @@ General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
"""
from __future__ import annotations
import copy
import random
import logging
from typing import TYPE_CHECKING, Iterator
from pathlib import Path
from novelwriter.enum import nwItemClass, nwItemLayout
from novelwriter.enum import nwItemClass, nwItemLayout, nwItemType
from novelwriter.error import logException
from novelwriter.common import checkHandle
from novelwriter.constants import nwFiles
from novelwriter.core.item import NWItem
if TYPE_CHECKING: # pragma: no cover
from novelwriter.core.project import NWProject
logger = logging.getLogger(__name__)
@@ -41,15 +47,16 @@ class NWTree:
MAX_DEPTH = 1000 # Cap of tree traversing for loops
def __init__(self, theProject):
def __init__(self, project: NWProject) -> None:
self.theProject = theProject
self._project = project
self._projTree = {} # Holds all the items of the project
self._treeOrder = [] # The order of the tree items on the tree view
self._treeRoots = {} # The root items of the tree
self._trashRoot = None # The handle of the trash root folder
self._archRoot = None # The handle of the archive root folder
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._treeRoots: dict[str, NWItem] = {} # The root items of the tree
self._trashRoot = None # The handle of the trash root folder
self._archRoot = None # The handle of the archive root folder
self._treeChanged = False # True if tree structure has changed
return
@@ -58,9 +65,8 @@ class NWTree:
# Class Methods
##
def clear(self):
"""Clear the item tree entirely.
"""
def clear(self) -> None:
"""Clear the item tree entirely."""
self._projTree = {}
self._treeOrder = []
self._treeRoots = {}
@@ -69,14 +75,12 @@ class NWTree:
self._treeChanged = False
return
def handles(self):
"""Returns a copy of the list of all the active handles.
"""
def handles(self) -> list[str]:
"""Returns a copy of the list of all the active handles."""
return self._treeOrder.copy()
def append(self, tHandle, pHandle, nwItem):
"""Add a new item to the end of the tree.
"""
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:
@@ -111,9 +115,19 @@ class NWTree:
return True
def pack(self):
"""Pack the content of the tree into the provided XML object. In
the order defined by the _treeOrder list.
def duplicate(self, sHandle: str) -> NWItem | None:
"""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):
logger.info("Duplicated item '%s' -> '%s'", sHandle, nItem.itemHandle)
return nItem
return None
def pack(self) -> list[dict]:
"""Pack the content of the tree into a list of doctionaries of
items. In the order defined by the _treeOrder list.
"""
tree = []
for tHandle in self._treeOrder:
@@ -122,25 +136,24 @@ class NWTree:
tree.append(tItem.pack())
return tree
def unpack(self, data):
def unpack(self, data: list[dict]) -> None:
"""Iterate through all items of a list and add them to the
project tree.
"""
self.clear()
for item in data:
nwItem = NWItem(self.theProject)
nwItem = NWItem(self._project)
if nwItem.unpack(item):
self.append(nwItem.itemHandle, nwItem.itemParent, nwItem)
nwItem.saveInitialCount()
return
return True
def writeToCFile(self):
def writeToCFile(self) -> bool:
"""Write the convenience table of contents file in the root of
the project directory.
"""
runtimePath = self.theProject.storage.runtimePath
contentPath = self.theProject.storage.contentPath
runtimePath = self._project.storage.runtimePath
contentPath = self._project.storage.contentPath
if not (isinstance(contentPath, Path) and isinstance(runtimePath, Path)):
return False
@@ -184,9 +197,8 @@ class NWTree:
return True
def sumWords(self):
"""Loop over all entries and add up the word counts.
"""
def sumWords(self) -> tuple[int, int]:
"""Loop over all entries and add up the word counts."""
noteWords = 0
novelWords = 0
for tHandle in self._treeOrder:
@@ -205,7 +217,7 @@ class NWTree:
# Tree Item Methods
##
def updateItemData(self, tHandle):
def updateItemData(self, tHandle: str) -> bool:
"""Update the root item handle of a given item. Returns True if
a root was found and data updated, otherwise False.
"""
@@ -226,15 +238,14 @@ class NWTree:
else:
raise RecursionError("Critical internal error")
def checkType(self, tHandle, itemType):
"""Return true of item exists and is of the specified item type.
"""
def checkType(self, tHandle: str, itemType: nwItemType) -> bool:
"""Check if item exists and is of the specified item type."""
tItem = self.__getitem__(tHandle)
if not tItem:
return False
return tItem.itemType == itemType
def getItemPath(self, tHandle):
def getItemPath(self, tHandle: str) -> list[str]:
"""Iterate upwards in the tree until we find the item with
parent None, the root item, and return the list of handles.
We do this with a for loop with a maximum depth to make
@@ -263,17 +274,15 @@ class NWTree:
# Tree Root Methods
##
def rootClasses(self):
"""Return a set of all root classes in use by the project.
"""
def rootClasses(self) -> set[nwItemClass]:
"""Return a set of all root classes in use by the project."""
rootClasses = set()
for nwItem in self._treeRoots.values():
rootClasses.add(nwItem.itemClass)
return rootClasses
def iterRoots(self, itemClass):
"""Iterate over all root items of a given class in order.
"""
def iterRoots(self, itemClass: nwItemClass | None) -> Iterator[tuple[str, NWItem]]:
"""Iterate over all root items of a given class in order."""
for tHandle in self._treeOrder:
nwItem = self.__getitem__(tHandle)
if isinstance(nwItem, NWItem) and nwItem.isRootType():
@@ -281,14 +290,8 @@ class NWTree:
yield tHandle, nwItem
return
def isRoot(self, tHandle):
"""Check if a handle is a root item.
"""
return tHandle in self._treeRoots
def isTrash(self, tHandle):
"""Check if an item is in or is the trash folder.
"""
def isTrash(self, tHandle: str) -> bool:
"""Check if an item is in or is the trash folder."""
tItem = self.__getitem__(tHandle)
if tItem is None:
return True
@@ -303,7 +306,7 @@ class NWTree:
return True
return False
def trashRoot(self):
def trashRoot(self) -> str | None:
"""Returns the handle of the trash folder, or None if there
isn't one.
"""
@@ -311,14 +314,13 @@ class NWTree:
return self._trashRoot
return None
def findRoot(self, theClass):
"""Find the first root item for a given class.
"""
def findRoot(self, itemClass: nwItemClass) -> str | None:
"""Find the first root item for a given class."""
for aRoot in self._treeRoots:
tItem = self.__getitem__(aRoot)
if tItem is None:
continue
if theClass == tItem.itemClass:
if itemClass == tItem.itemClass:
return tItem.itemHandle
return None
@@ -326,9 +328,8 @@ class NWTree:
# Setters
##
def setOrder(self, newOrder):
"""Reorders the tree based on a list of items.
"""
def setOrder(self, newOrder: list[str]) -> None:
"""Reorders the tree based on a list of items."""
tmpOrder = [tHandle for tHandle in newOrder if tHandle in self._projTree]
if not (len(tmpOrder) == len(newOrder) == len(self._treeOrder)):
# Something is wrong, so let's debug it
@@ -346,48 +347,29 @@ class NWTree:
return
def setFileItemLayout(self, tHandle, itemLayout):
"""Set the nwItemLayout for a specific file.
"""
tItem = self.__getitem__(tHandle)
if tItem is None:
return False
if not tItem.isFileType():
logger.error("Item '%s' is not a file", tHandle)
return False
if not isinstance(itemLayout, nwItemLayout):
return False
tItem.setLayout(itemLayout)
return True
##
# Special Methods
##
def __len__(self):
"""The number of items in the project.
"""
def __len__(self) -> int:
"""The number of items in the project."""
return len(self._treeOrder)
def __bool__(self):
"""True if there are any items in the project.
"""
def __bool__(self) -> bool:
"""True if there are any items in the project."""
return bool(self._treeOrder)
def __getitem__(self, tHandle):
def __getitem__(self, tHandle: str | None) -> NWItem | None:
"""Return a project item based on its handle. Returns None if
the handle doesn't exist in the project.
"""
if tHandle in self._projTree:
if tHandle and tHandle in self._projTree:
return self._projTree[tHandle]
logger.error("No tree item with handle '%s'", str(tHandle))
return None
def __delitem__(self, tHandle):
"""Remove an item from the internal lists and dictionaries.
"""
def __delitem__(self, tHandle: str) -> None:
"""Remove an item from the internal lists and dictionaries."""
if tHandle in self._treeOrder and tHandle in self._projTree:
self._treeOrder.remove(tHandle)
del self._projTree[tHandle]
@@ -406,14 +388,12 @@ class NWTree:
return
def __contains__(self, tHandle):
"""Checks if a handle exists in the tree.
"""
def __contains__(self, tHandle: str) -> bool:
"""Checks if a handle exists in the tree."""
return tHandle in self._treeOrder
def __iter__(self):
"""Iterate through project items.
"""
def __iter__(self) -> Iterator[NWItem]:
"""Iterate through project items."""
for tHandle in self._treeOrder:
tItem = self._projTree.get(tHandle)
if isinstance(tItem, NWItem):
@@ -424,16 +404,16 @@ class NWTree:
# Internal Functions
##
def _setTreeChanged(self, theState):
def _setTreeChanged(self, state: bool) -> None:
"""Set the changed flag to theState, and if being set to True,
propagate that state change to the parent NWProject class.
"""
self._treeChanged = theState
if theState:
self.theProject.setProjectChanged(True)
self._treeChanged = state
if state:
self._project.setProjectChanged(True)
return
def _makeHandle(self):
def _makeHandle(self) -> str:
"""Generate a unique item handle. In the event that the key
already exists, generate a new one.
"""
+96 -59
View File
@@ -24,11 +24,13 @@ General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
"""
from __future__ import annotations
import logging
from enum import Enum
from time import time
from typing import TYPE_CHECKING
from PyQt5.QtGui import QPalette
from PyQt5.QtCore import Qt, QSize, pyqtSignal, pyqtSlot
@@ -42,12 +44,15 @@ from novelwriter import CONFIG
from novelwriter.enum import nwDocMode, nwItemType, nwItemClass, nwItemLayout, nwAlert, nwWidget
from novelwriter.constants import nwHeaders, nwUnicode, trConst, nwLabels
from novelwriter.core.item import NWItem
from novelwriter.core.coretools import DocMerger, DocSplitter
from novelwriter.core.coretools import DocDuplicator, DocMerger, DocSplitter
from novelwriter.dialogs.docmerge import GuiDocMerge
from novelwriter.dialogs.docsplit import GuiDocSplit
from novelwriter.dialogs.editlabel import GuiEditLabel
from novelwriter.dialogs.projsettings import GuiProjectSettings
if TYPE_CHECKING: # pragma: no cover
from novelwriter.guimain import GuiMain
logger = logging.getLogger(__name__)
@@ -69,7 +74,7 @@ class GuiProjectView(QWidget):
# Requests for the main GUI
projectSettingsRequest = pyqtSignal(int)
def __init__(self, mainGui):
def __init__(self, mainGui: GuiMain):
super().__init__(parent=mainGui)
self.mainGui = mainGui
@@ -452,7 +457,7 @@ class GuiProjectTree(QTreeWidget):
D_HANDLE = Qt.ItemDataRole.UserRole
D_WORDS = Qt.ItemDataRole.UserRole + 1
def __init__(self, projView):
def __init__(self, projView: GuiProjectView):
super().__init__(parent=projView)
logger.debug("Create: GuiProjectTree")
@@ -653,11 +658,12 @@ class GuiProjectTree(QTreeWidget):
return True
def revealNewTreeItem(self, tHandle, nHandle=None, wordCount=False):
"""Reveal a newly added project item in the project tree.
"""
def revealNewTreeItem(
self, tHandle: str, nHandle: str | None = None, wordCount: bool = False
) -> bool:
"""Reveal a newly added project item in the project tree."""
nwItem = self.theProject.tree[tHandle]
if nwItem is None:
if not nwItem:
return False
trItem = self._addTreeItem(nwItem, nHandle)
@@ -678,9 +684,8 @@ class GuiProjectTree(QTreeWidget):
return True
def moveTreeItem(self, nStep):
"""Move an item up or down in the tree.
"""
def moveTreeItem(self, nStep: int) -> bool:
"""Move an item up or down in the tree."""
tHandle = self.getSelectedHandle()
trItem = self._getTreeItem(tHandle)
if trItem is None:
@@ -718,9 +723,8 @@ class GuiProjectTree(QTreeWidget):
return True
def renameTreeItem(self, tHandle):
"""Open a dialog to edit the label of an item.
"""
def renameTreeItem(self, tHandle: str) -> bool:
"""Open a dialog to edit the label of an item."""
tItem = self.theProject.tree[tHandle]
if tItem is None:
return False
@@ -733,7 +737,7 @@ class GuiProjectTree(QTreeWidget):
return True
def saveTreeOrder(self):
def saveTreeOrder(self) -> None:
"""Build a list of the items in the project tree and send them
to the project class. This syncs up the two versions of the
project structure, and must be called before any code that
@@ -741,12 +745,14 @@ class GuiProjectTree(QTreeWidget):
"""
theList = []
for i in range(self.topLevelItemCount()):
theList = self._scanChildren(theList, self.topLevelItem(i), i)
item = self.topLevelItem(i)
if isinstance(item, QTreeWidgetItem):
theList = self._scanChildren(theList, item, i)
logger.debug("Saving project tree item order")
self.theProject.setTreeOrder(theList)
return True
return
def getTreeFromHandle(self, tHandle):
def getTreeFromHandle(self, tHandle: str) -> list[str]:
"""Recursively return all the child items starting from a given
item handle.
"""
@@ -756,7 +762,7 @@ class GuiProjectTree(QTreeWidget):
theList = self._scanChildren(theList, theItem, 0)
return theList
def requestDeleteItem(self, tHandle=None):
def requestDeleteItem(self, tHandle: str | None = None) -> bool:
"""Request an item deleted from the project tree. This function
can be called on any item, and will check whether to attempt a
permanent deletion or moving the item to Trash.
@@ -993,7 +999,7 @@ class GuiProjectTree(QTreeWidget):
return
def propagateCount(self, tHandle, newCount, countChildren=False):
def propagateCount(self, tHandle: str, newCount: int, countChildren: bool = False) -> None:
"""Recursive function setting the word count for a given item,
and propagating that count upwards in the tree until reaching a
root item. This function is more efficient than recalculating
@@ -1032,7 +1038,7 @@ class GuiProjectTree(QTreeWidget):
return
def buildTree(self):
def buildTree(self) -> None:
"""Build the entire project tree from scratch. This depends on
the save project item iterator in the project class which will
always make sure items with a parent have had their parent item
@@ -1047,11 +1053,10 @@ class GuiProjectTree(QTreeWidget):
self._addTreeItem(nwItem)
logger.debug("%d item(s) added to the project tree", iCount)
return True
return
def undoLastMove(self):
"""Attempt to undo the last action.
"""
"""Attempt to undo the last action."""
srcItem = self._lastMove.get("item", None)
dstItem = self._lastMove.get("parent", None)
dstIndex = self._lastMove.get("index", None)
@@ -1308,8 +1313,8 @@ class GuiProjectTree(QTreeWidget):
aSplit1 = mTrans.addAction(self.tr("Split Document by Headers"))
aSplit1.triggered.connect(lambda: self._splitDocument(tHandle))
# Expand/Collapse/Delete
# ======================
# Expand/Collapse/Delete/Duplicate
# ================================
ctxMenu.addSeparator()
@@ -1318,6 +1323,11 @@ class GuiProjectTree(QTreeWidget):
aExpand.triggered.connect(lambda: self.setExpandedFromHandle(tHandle, True))
aCollapse = ctxMenu.addAction(self.tr("Collapse All"))
aCollapse.triggered.connect(lambda: self.setExpandedFromHandle(tHandle, False))
aDuplicate = ctxMenu.addAction(self.tr("Duplicate from Here"))
aDuplicate.triggered.connect(lambda: self._duplicateFromHandle(tHandle))
elif isFile:
aDuplicate = ctxMenu.addAction(self.tr("Duplicate Document"))
aDuplicate.triggered.connect(lambda: self._duplicateFromHandle(tHandle))
if tItem.itemClass == nwItemClass.TRASH or isRoot or (isFolder and not hasChild):
aDelete = ctxMenu.addAction(self.tr("Delete Permanently"))
@@ -1532,8 +1542,7 @@ class GuiProjectTree(QTreeWidget):
return
def _mergeDocuments(self, tHandle, newFile):
"""Merge an item's child documents into a single document.
"""
"""Merge an item's child documents into a single document."""
logger.info("Request to merge items under handle '%s'", tHandle)
itemList = self.getTreeFromHandle(tHandle)
@@ -1608,8 +1617,7 @@ class GuiProjectTree(QTreeWidget):
return True
def _splitDocument(self, tHandle):
"""Split a document into multiple documents.
"""
"""Split a document into multiple documents."""
logger.info("Request to split items with handle '%s'", tHandle)
tItem = self.theProject.tree[tHandle]
@@ -1660,7 +1668,38 @@ class GuiProjectTree(QTreeWidget):
return True
def _scanChildren(self, theList, tItem, tIndex):
def _duplicateFromHandle(self, tHandle: str) -> bool:
"""Duplicate the item hierarchy from a given item."""
itemTree = self.getTreeFromHandle(tHandle)
nItems = len(itemTree)
if nItems == 0:
return False
elif nItems == 1:
qTitle = self.tr("Duplicate Document")
qText = self.tr("Do you want to duplicate this document?")
else:
qTitle = self.tr("Duplicate from Here")
qText = self.tr("Do you want to duplicate this item and all child items?")
if not self.mainGui.askQuestion(qTitle, qText):
return False
docDup = DocDuplicator(self.theProject)
dupCount = 0
for dHandle, nHandle in docDup.duplicate(itemTree):
self.theProject.index.reIndexHandle(dHandle)
self.revealNewTreeItem(dHandle, nHandle=nHandle, wordCount=True)
self._alertTreeChange(dHandle, flush=False)
dupCount += 1
if dupCount != nItems:
self.mainGui.makeAlert(self.tr("Could not duplicate all items."), nwAlert.WARN)
self.saveTreeOrder()
return True
def _scanChildren(self, itemList: list, tItem: QTreeWidgetItem, tIndex: int):
"""This is a recursive function returning all items in a tree
starting at a given QTreeWidgetItem.
"""
@@ -1673,16 +1712,23 @@ class GuiProjectTree(QTreeWidget):
nwItem.setExpanded(tItem.isExpanded() and cCount > 0)
nwItem.setOrder(tIndex)
theList.append(tHandle)
itemList.append(tHandle)
for i in range(cCount):
self._scanChildren(theList, tItem.child(i), i)
self._scanChildren(itemList, tItem.child(i), i)
return theList
return itemList
def _addTreeItem(self, nwItem, nHandle=None):
def _addTreeItem(
self, nwItem: NWItem | None, nHandle: str | None = None
) -> QTreeWidgetItem | None:
"""Create a QTreeWidgetItem from an NWItem and add it to the
project tree.
project tree. Returns the widget if the item is valid, otherwise
a None is returned.
"""
if not nwItem:
logger.error("Invalid item cannot be added to project tree")
return None
tHandle = nwItem.itemHandle
pHandle = nwItem.itemParent
newItem = QTreeWidgetItem()
@@ -1700,35 +1746,26 @@ class GuiProjectTree(QTreeWidget):
newItem.setData(self.C_DATA, self.D_HANDLE, tHandle)
newItem.setData(self.C_DATA, self.D_WORDS, 0)
self._treeMap[tHandle] = newItem
if pHandle is None:
if nwItem.isRootType():
newItem.setFlags(newItem.flags() ^ Qt.ItemIsDragEnabled)
self.addTopLevelItem(newItem)
else:
self.mainGui.makeAlert(self.tr(
"There is nowhere to add item with name '{0}'."
).format(nwItem.itemName), nwAlert.ERROR)
del self._treeMap[tHandle]
return None
elif pHandle in self._treeMap:
byIndex = -1
if nHandle is not None and nHandle in self._treeMap:
byIndex = self._treeMap[pHandle].indexOfChild(self._treeMap[nHandle])
if byIndex >= 0:
self._treeMap[pHandle].insertChild(byIndex + 1, newItem)
else:
self._treeMap[pHandle].addChild(newItem)
self.propagateCount(tHandle, nwItem.wordCount, countChildren=True)
if pHandle is None and nwItem.isRootType():
pItem = self.invisibleRootItem()
elif pHandle and pHandle in self._treeMap:
pItem = self._treeMap[pHandle]
else:
self.mainGui.makeAlert(self.tr(
"There is nowhere to add item with name '{0}'."
).format(nwItem.itemName), nwAlert.ERROR)
del self._treeMap[tHandle]
return None
byIndex = -1
if nHandle is not None and nHandle in self._treeMap:
byIndex = pItem.indexOfChild(self._treeMap[nHandle])
if byIndex >= 0:
pItem.insertChild(byIndex + 1, newItem)
else:
pItem.addChild(newItem)
self._treeMap[tHandle] = newItem
self.propagateCount(tHandle, nwItem.wordCount, countChildren=True)
self.setTreeItemValues(tHandle)
newItem.setExpanded(nwItem.isExpanded)
@@ -1768,7 +1805,7 @@ class GuiProjectTree(QTreeWidget):
return
tItem = self.theProject.tree[tHandle]
if tItem.isRootType():
if tItem and tItem.isRootType():
self.projView.rootFolderChanged.emit(tHandle)
self.projView.treeItemChanged.emit(tHandle)
@@ -0,0 +1,106 @@
<?xml version='1.0' encoding='utf-8'?>
<novelWriterXML appVersion="2.1-beta1" hexVersion="0x020100b1" fileVersion="1.5" fileRevision="1" timeStamp="2023-07-20 20:33:41">
<project id="d0f3fe10-c6e6-4310-8bfd-181eb4224eed" saveCount="1" autoCount="1" editTime="0">
<name>New Project</name>
<title>New Novel</title>
<author>Jane Doe</author>
</project>
<settings>
<doBackup>yes</doBackup>
<language>None</language>
<spellChecking auto="no">None</spellChecking>
<lastHandle>
<entry key="editor">None</entry>
<entry key="viewer">None</entry>
<entry key="novelTree">None</entry>
<entry key="outline">None</entry>
</lastHandle>
<autoReplace />
<status>
<entry key="s000000" count="15" red="100" green="100" blue="100">New</entry>
<entry key="s000001" count="0" red="200" green="50" blue="0">Note</entry>
<entry key="s000002" count="0" red="200" green="150" blue="0">Draft</entry>
<entry key="s000003" count="0" red="50" green="200" blue="0">Finished</entry>
</status>
<importance>
<entry key="i000004" count="3" red="100" green="100" blue="100">New</entry>
<entry key="i000005" count="0" red="200" green="50" blue="0">Minor</entry>
<entry key="i000006" count="0" red="200" green="150" blue="0">Major</entry>
<entry key="i000007" count="0" red="50" green="200" blue="0">Main</entry>
</importance>
</settings>
<content items="18" novelWords="26" notesWords="0">
<item handle="0000000000008" parent="None" root="0000000000008" order="0" type="ROOT" class="NOVEL">
<meta expanded="no" />
<name status="s000000" import="i000004">Novel</name>
</item>
<item handle="0000000000009" parent="None" root="0000000000009" order="0" type="ROOT" class="PLOT">
<meta expanded="no" />
<name status="s000000" import="i000004">Plot</name>
</item>
<item handle="000000000000a" parent="None" root="000000000000a" order="0" type="ROOT" class="CHARACTER">
<meta expanded="no" />
<name status="s000000" import="i000004">Characters</name>
</item>
<item handle="000000000000b" parent="None" root="000000000000b" order="0" type="ROOT" class="WORLD">
<meta expanded="no" />
<name status="s000000" import="i000004">World</name>
</item>
<item handle="000000000000c" parent="0000000000008" root="0000000000008" order="0" type="FILE" class="NOVEL" layout="DOCUMENT">
<meta expanded="no" heading="H1" charCount="20" wordCount="5" paraCount="1" cursorPos="0" />
<name status="s000000" import="i000004" active="yes">Title Page</name>
</item>
<item handle="000000000000d" parent="0000000000008" root="0000000000008" order="0" type="FOLDER" class="NOVEL">
<meta expanded="no" />
<name status="s000000" import="i000004">New Chapter</name>
</item>
<item handle="000000000000e" parent="000000000000d" root="0000000000008" order="0" type="FILE" class="NOVEL" layout="DOCUMENT">
<meta expanded="no" heading="H2" charCount="11" wordCount="2" paraCount="0" cursorPos="0" />
<name status="s000000" import="i000004" active="yes">New Chapter</name>
</item>
<item handle="000000000000f" parent="000000000000d" root="0000000000008" order="0" type="FILE" class="NOVEL" layout="DOCUMENT">
<meta expanded="no" heading="H3" charCount="9" wordCount="2" paraCount="0" cursorPos="0" />
<name status="s000000" import="i000004" active="yes">New Scene</name>
</item>
<item handle="0000000000010" parent="000000000000d" root="0000000000008" order="0" type="FILE" class="NOVEL" layout="DOCUMENT">
<meta expanded="no" heading="H3" charCount="9" wordCount="2" paraCount="0" cursorPos="0" />
<name status="s000000" import="i000004" active="yes">New Scene</name>
</item>
<item handle="0000000000011" parent="0000000000008" root="0000000000008" order="0" type="FOLDER" class="NOVEL">
<meta expanded="no" />
<name status="s000000" import="i000004">New Chapter</name>
</item>
<item handle="0000000000012" parent="0000000000011" root="0000000000008" order="0" type="FILE" class="NOVEL" layout="DOCUMENT">
<meta expanded="no" heading="H2" charCount="11" wordCount="2" paraCount="0" cursorPos="0" />
<name status="s000000" import="i000004" active="yes">New Chapter</name>
</item>
<item handle="0000000000013" parent="0000000000011" root="0000000000008" order="0" type="FILE" class="NOVEL" layout="DOCUMENT">
<meta expanded="no" heading="H3" charCount="9" wordCount="2" paraCount="0" cursorPos="0" />
<name status="s000000" import="i000004" active="yes">New Scene</name>
</item>
<item handle="0000000000014" parent="None" root="0000000000008" order="0" type="ROOT" class="NOVEL">
<meta expanded="no" />
<name status="s000000" import="i000004">Novel</name>
</item>
<item handle="0000000000015" parent="0000000000014" root="0000000000014" order="0" type="FILE" class="NOVEL" layout="DOCUMENT">
<meta expanded="no" heading="H1" charCount="20" wordCount="5" paraCount="1" cursorPos="0" />
<name status="s000000" import="i000004" active="yes">Title Page</name>
</item>
<item handle="0000000000016" parent="0000000000014" root="0000000000014" order="0" type="FOLDER" class="NOVEL">
<meta expanded="no" />
<name status="s000000" import="i000004">New Chapter</name>
</item>
<item handle="0000000000017" parent="0000000000016" root="0000000000014" order="0" type="FILE" class="NOVEL" layout="DOCUMENT">
<meta expanded="no" heading="H2" charCount="11" wordCount="2" paraCount="0" cursorPos="0" />
<name status="s000000" import="i000004" active="yes">New Chapter</name>
</item>
<item handle="0000000000018" parent="0000000000016" root="0000000000014" order="0" type="FILE" class="NOVEL" layout="DOCUMENT">
<meta expanded="no" heading="H3" charCount="9" wordCount="2" paraCount="0" cursorPos="0" />
<name status="s000000" import="i000004" active="yes">New Scene</name>
</item>
<item handle="0000000000019" parent="000000000000d" root="0000000000008" order="0" type="FILE" class="NOVEL" layout="DOCUMENT">
<meta expanded="no" heading="H2" charCount="11" wordCount="2" paraCount="0" cursorPos="0" />
<name status="s000000" import="i000004" active="yes">New Chapter</name>
</item>
</content>
</novelWriterXML>
+141 -5
View File
@@ -23,6 +23,7 @@ import uuid
import pytest
from shutil import copyfile
from pathlib import Path
from zipfile import ZipFile
from mocked import causeOSError
@@ -31,13 +32,12 @@ from tools import C, buildTestProject, cmpFiles, XML_IGNORE
from novelwriter import CONFIG
from novelwriter.constants import nwItemClass
from novelwriter.core.project import NWProject
from novelwriter.core.coretools import DocMerger, DocSplitter, ProjectBuilder
from novelwriter.core.coretools import DocDuplicator, DocMerger, DocSplitter, ProjectBuilder
@pytest.mark.core
def testCoreTools_DocMerger(monkeypatch, mockGUI, fncPath, tstPaths, mockRnd, ipsumText):
"""Test the DocMerger utility.
"""
"""Test the DocMerger utility."""
theProject = NWProject(mockGUI)
mockRnd.reset()
buildTestProject(theProject, fncPath)
@@ -125,8 +125,7 @@ def testCoreTools_DocMerger(monkeypatch, mockGUI, fncPath, tstPaths, mockRnd, ip
@pytest.mark.core
def testCoreTools_DocSplitter(monkeypatch, mockGUI, fncPath, mockRnd, ipsumText):
"""Test the DocSplitter utility.
"""
"""Test the DocSplitter utility."""
theProject = NWProject(mockGUI)
mockRnd.reset()
buildTestProject(theProject, fncPath)
@@ -263,6 +262,143 @@ def testCoreTools_DocSplitter(monkeypatch, mockGUI, fncPath, mockRnd, ipsumText)
# END Test testCoreTools_DocSplitter
@pytest.mark.core
def testCoreTools_DocDuplicator(mockGUI, fncPath, tstPaths, mockRnd):
"""Test the DocDuplicator utility."""
theProject = NWProject(mockGUI)
mockRnd.reset()
buildTestProject(theProject, fncPath)
dup = DocDuplicator(theProject)
ttText = "#! New Novel\n\n>> By Jane Doe <<\n"
chText = "## New Chapter\n\n"
scText = "### New Scene\n\n"
# Check document content
assert theProject.storage.getDocument(C.hTitlePage).readDocument() == ttText
assert theProject.storage.getDocument(C.hChapterDoc).readDocument() == chText
assert theProject.storage.getDocument(C.hSceneDoc).readDocument() == scText
# Nothing to do
assert list(dup.duplicate([])) == []
# Single Document
# ===============
# A new copy is created
assert list(dup.duplicate([C.hSceneDoc])) == [
("0000000000010", C.hSceneDoc), # The Scene
]
assert theProject.tree._treeOrder == [
C.hNovelRoot, C.hPlotRoot, C.hCharRoot, C.hWorldRoot,
C.hTitlePage, C.hChapterDir, C.hChapterDoc, C.hSceneDoc,
"0000000000010",
]
# With the same content
assert theProject.storage.getDocument("0000000000010").readDocument() == scText
# They should have the same parent
assert theProject.tree["0000000000010"].itemParent == C.hChapterDir # type: ignore
# Folder w/Two Files
# ==================
# The folder is copied, with two docs
assert list(dup.duplicate([C.hChapterDir, C.hChapterDoc, C.hSceneDoc])) == [
("0000000000011", C.hChapterDir), # The Folder
("0000000000012", None), # The Chapter
("0000000000013", None), # The Scene
]
assert theProject.tree._treeOrder == [
C.hNovelRoot, C.hPlotRoot, C.hCharRoot, C.hWorldRoot,
C.hTitlePage, C.hChapterDir, C.hChapterDoc, C.hSceneDoc,
"0000000000010",
"0000000000011", "0000000000012", "0000000000013",
]
# With the same content
assert theProject.storage.getDocument("0000000000012").readDocument() == chText
assert theProject.storage.getDocument("0000000000013").readDocument() == scText
# The chapter dirs should have the same parent
assert theProject.tree["0000000000011"].itemParent == C.hNovelRoot # type: ignore
# The new files should have the new folder as parent
assert theProject.tree["0000000000012"].itemParent == "0000000000011" # type: ignore
assert theProject.tree["0000000000013"].itemParent == "0000000000011" # type: ignore
# Full Root Folder
# ================
# The root is copied, with three docs and a folder
assert list(dup.duplicate(
[C.hNovelRoot, C.hTitlePage, C.hChapterDir, C.hChapterDoc, C.hSceneDoc]
)) == [
("0000000000014", C.hNovelRoot), # The Root
("0000000000015", None), # The Title Page
("0000000000016", None), # The Folder
("0000000000017", None), # The Chapter
("0000000000018", None), # The Scene
]
assert theProject.tree._treeOrder == [
C.hNovelRoot, C.hPlotRoot, C.hCharRoot, C.hWorldRoot,
C.hTitlePage, C.hChapterDir, C.hChapterDoc, C.hSceneDoc,
"0000000000010",
"0000000000011", "0000000000012", "0000000000013",
"0000000000014", "0000000000015", "0000000000016", "0000000000017", "0000000000018",
]
# With the same content
assert theProject.storage.getDocument("0000000000015").readDocument() == ttText
assert theProject.storage.getDocument("0000000000017").readDocument() == chText
assert theProject.storage.getDocument("0000000000018").readDocument() == scText
# The root folder should have no parent
assert theProject.tree["0000000000014"].itemParent is None # type: ignore
# The folder and files should have the new root
assert theProject.tree["0000000000015"].itemRoot == "0000000000014" # type: ignore
assert theProject.tree["0000000000016"].itemRoot == "0000000000014" # type: ignore
assert theProject.tree["0000000000017"].itemRoot == "0000000000014" # type: ignore
assert theProject.tree["0000000000018"].itemRoot == "0000000000014" # type: ignore
# And they should have new parents
assert theProject.tree["0000000000015"].itemParent == "0000000000014" # type: ignore
assert theProject.tree["0000000000016"].itemParent == "0000000000014" # type: ignore
assert theProject.tree["0000000000017"].itemParent == "0000000000016" # type: ignore
assert theProject.tree["0000000000018"].itemParent == "0000000000016" # type: ignore
# Exceptions
# ==========
# Handle invalid items
assert list(dup.duplicate([C.hInvalid])) == []
# Also stop early if invalid items are encountered
assert list(dup.duplicate([C.hInvalid, C.hSceneDoc])) == []
# Don't overwrite existing files
content = theProject.storage.contentPath
assert isinstance(content, Path)
(content / "0000000000019.nwd").touch()
assert (content / "0000000000019.nwd").exists()
assert list(dup.duplicate([C.hChapterDoc, C.hSceneDoc])) == []
# Save and Close
theProject.saveProject()
projFile = fncPath / "nwProject.nwx"
testFile = tstPaths.outDir / "coreTools_DocDuplicator_nwProject.nwx"
compFile = tstPaths.refDir / "coreTools_DocDuplicator_nwProject.nwx"
copyfile(projFile, testFile)
assert cmpFiles(testFile, compFile, ignoreStart=XML_IGNORE)
# END Test testCoreTools_DocDuplicator
@pytest.mark.core
def testCoreTools_NewMinimal(monkeypatch, fncPath, tstPaths, mockGUI, mockRnd):
"""Create a new project from a project wizard dictionary. With
+6 -2
View File
@@ -31,8 +31,7 @@ from novelwriter.core.document import NWDocument
@pytest.mark.core
def testCoreDocument_LoadSave(monkeypatch, mockGUI, fncPath, mockRnd):
"""Test loading and saving a document with the NWDocument class.
"""
"""Test loading and saving a document with the NWDocument class."""
theProject = NWProject(mockGUI)
mockRnd.reset()
buildTestProject(theProject, fncPath)
@@ -44,27 +43,32 @@ def testCoreDocument_LoadSave(monkeypatch, mockGUI, fncPath, mockRnd):
theDoc = NWDocument(theProject, "stuff")
assert bool(theDoc) is False
assert theDoc.readDocument() is None
assert theDoc.fileExists() is False
# Non-existent handle
theDoc = NWDocument(theProject, C.hInvalid)
assert theDoc.readDocument() is None
assert theDoc._currHash is None
assert theDoc.fileExists() is False
# No content path
with monkeypatch.context() as mp:
mp.setattr("novelwriter.core.storage.NWStorage.contentPath", property(lambda *a: None))
theDoc = NWDocument(theProject, C.hSceneDoc)
assert theDoc.readDocument() is None
assert theDoc.fileExists() is False
# Cause open() to fail while loading
with monkeypatch.context() as mp:
mp.setattr("builtins.open", causeOSError)
theDoc = NWDocument(theProject, C.hSceneDoc)
assert theDoc.fileExists() is True
assert theDoc.readDocument() is None
assert theDoc.getError() == "OSError: Mock OSError"
# Load the text
theDoc = NWDocument(theProject, C.hSceneDoc)
assert theDoc.fileExists() is True
assert theDoc.readDocument() == "### New Scene\n\n"
# Try to open a new (non-existent) file
+72 -15
View File
@@ -19,6 +19,7 @@ You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
"""
import copy
import pytest
from PyQt5.QtGui import QIcon
@@ -32,8 +33,7 @@ from novelwriter.core.project import NWProject
@pytest.mark.core
def testCoreItem_Setters(mockGUI, mockRnd, fncPath):
"""Test all the simple setters for the NWItem class.
"""
"""Test all the simple setters for the NWItem class."""
theProject = NWProject(mockGUI)
mockRnd.reset()
buildTestProject(theProject, fncPath)
@@ -193,8 +193,7 @@ def testCoreItem_Setters(mockGUI, mockRnd, fncPath):
@pytest.mark.core
def testCoreItem_Methods(mockGUI, mockRnd, fncPath):
"""Test the simple methods of the NWItem class.
"""
"""Test the simple methods of the NWItem class."""
theProject = NWProject(mockGUI)
mockRnd.reset()
buildTestProject(theProject, fncPath)
@@ -262,19 +261,11 @@ def testCoreItem_Methods(mockGUI, mockRnd, fncPath):
assert stT == "Note"
assert isinstance(stI, QIcon)
theItem.setImportStatus(C.sDraft)
stT, stI = theItem.getImportStatus()
assert stT == "Draft"
theItem.setClass("CHARACTER")
stT, stI = theItem.getImportStatus()
assert stT == "Minor"
assert isinstance(stI, QIcon)
theItem.setImportStatus(C.iMajor)
stT, stI = theItem.getImportStatus()
assert stT == "Major"
# Representation
# ==============
@@ -286,9 +277,75 @@ def testCoreItem_Methods(mockGUI, mockRnd, fncPath):
# Truthiness
# ==========
assert bool(theItem) is True
theItem.setHandle(None)
assert bool(theItem) is False
bItem = NWItem(theProject)
# An item with a handle is valid
bItem.setHandle(theProject.tree._makeHandle())
assert bool(bItem) is True
assert bItem
# An item without a handle is invalid
bItem.setHandle(None)
assert bool(bItem) is False
assert not bItem
# Copy an Item
# ============
scData = {
"name": "New Scene",
"itemAttr": {
"handle": "000000000000f",
"parent": "000000000000d",
"root": "0000000000008",
"order": "0",
"type": "FILE",
"class": "NOVEL",
"layout": "DOCUMENT"
},
"metaAttr": {
"expanded": "no",
"heading": "H3",
"charCount": "9",
"wordCount": "2",
"paraCount": "0",
"cursorPos": "0"
},
"nameAttr": {
"status": "s000000",
"import": "i000004",
"active": "yes"
}
}
scItem = theProject.tree[C.hSceneDoc]
cpItem = copy.copy(scItem)
# We should have two instances of NWItem
assert isinstance(scItem, NWItem)
assert isinstance(cpItem, NWItem)
assert scItem is not cpItem
# They should both point to the same project instance
assert scItem._project is cpItem._project
# They should contain the same data
assert scItem.pack() == scData
assert cpItem.pack() == scData
# Create a new handle for the copy
cpHandle = theProject.tree._makeHandle()
cpData = copy.deepcopy(scData)
cpData["itemAttr"]["handle"] = cpHandle
# Check that it is indeed changed
cpItem.setHandle(cpHandle)
assert cpItem.pack() != scData
assert cpItem.pack() == cpData
# Delete the original, and check that the copy remains
del scItem
assert cpItem.pack() == cpData
# END Test testCoreItem_Methods
+1 -9
View File
@@ -149,7 +149,6 @@ def testCoreTree_BuildTree(mockGUI, mockItems):
assert theTree.trashRoot() == "a000000000003"
assert theTree.findRoot(nwItemClass.ARCHIVE) == "a000000000002"
assert theTree.isTrash("a000000000003") is True
assert theTree.isRoot("a000000000002") is True
# Check that we have the root classes
assert theTree.rootClasses() == {
@@ -255,7 +254,7 @@ def testCoreTree_PackUnpack(mockGUI, mockItems):
theTree.clear()
assert len(theTree) == 0
assert theTree.handles() == []
assert theTree.unpack(tree) is True
theTree.unpack(tree)
assert theTree.handles() == aHandles
# END Test testCoreTree_PackUnpack
@@ -339,13 +338,6 @@ def testCoreTree_Methods(mockGUI, mockItems):
"c000000000001", "b000000000001", "a000000000001"
]
# Change file layout
assert theTree.setFileItemLayout("stuff", nwItemLayout.DOCUMENT) is False
assert theTree.setFileItemLayout("b000000000001", nwItemLayout.DOCUMENT) is False
assert theTree.setFileItemLayout("c000000000001", "stuff") is False
assert theTree.setFileItemLayout("c000000000001", nwItemLayout.NOTE) is True
assert theTree["c000000000001"].itemLayout == nwItemLayout.NOTE
# END Test testCoreTree_Methods
+72 -17
View File
@@ -21,14 +21,17 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
import pytest
from mocked import causeOSError
from pathlib import Path
from tools import C, buildTestProject
from mocked import causeOSError
from PyQt5.QtCore import Qt
from PyQt5.QtWidgets import QMessageBox, QMenu, QTreeWidgetItem, QDialog
from novelwriter import CONFIG
from novelwriter.enum import nwItemLayout, nwItemType, nwItemClass
from novelwriter.guimain import GuiMain
from novelwriter.gui.projtree import GuiProjectTree
from novelwriter.dialogs.docmerge import GuiDocMerge
from novelwriter.dialogs.docsplit import GuiDocSplit
@@ -37,8 +40,7 @@ from novelwriter.dialogs.editlabel import GuiEditLabel
@pytest.mark.gui
def testGuiProjTree_NewItems(qtbot, caplog, monkeypatch, nwGUI, projPath, mockRnd):
"""Test adding and removing items from the project tree.
"""
"""Test adding and removing items from the project tree."""
monkeypatch.setattr(GuiEditLabel, "getLabel", lambda *a, text: (text, True))
projView = nwGUI.projView
@@ -159,6 +161,9 @@ def testGuiProjTree_NewItems(qtbot, caplog, monkeypatch, nwGUI, projPath, mockRn
nHandle = theProject.newFile("Test", None)
assert projView.projTree.revealNewTreeItem(nHandle) is False
# Adding an invalid item directly to the tree should also fail
assert projView.projTree._addTreeItem(None) is None
# Clean up
# qtbot.stop()
nwGUI.closeProject()
@@ -168,8 +173,7 @@ def testGuiProjTree_NewItems(qtbot, caplog, monkeypatch, nwGUI, projPath, mockRn
@pytest.mark.gui
def testGuiProjTree_MoveItems(qtbot, monkeypatch, nwGUI, projPath, mockRnd):
"""Test adding and removing items from the project tree.
"""
"""Test adding and removing items from the project tree."""
monkeypatch.setattr(GuiEditLabel, "getLabel", lambda *a, text: (text, True))
projView = nwGUI.projView
@@ -278,8 +282,7 @@ def testGuiProjTree_MoveItems(qtbot, monkeypatch, nwGUI, projPath, mockRnd):
@pytest.mark.gui
def testGuiProjTree_RequestDeleteItem(qtbot, caplog, monkeypatch, nwGUI, projPath, mockRnd):
"""Test external requests for removing items from project tree.
"""
"""Test external requests for removing items from project tree."""
monkeypatch.setattr(GuiEditLabel, "getLabel", lambda *a, text: (text, True))
projView = nwGUI.projView
@@ -361,8 +364,7 @@ def testGuiProjTree_RequestDeleteItem(qtbot, caplog, monkeypatch, nwGUI, projPat
@pytest.mark.gui
def testGuiProjTree_MoveItemToTrash(qtbot, caplog, monkeypatch, nwGUI, projPath, mockRnd):
"""Test moving items to Trash.
"""
"""Test moving items to Trash."""
monkeypatch.setattr(GuiEditLabel, "getLabel", lambda *a, text: (text, True))
theProject = nwGUI.theProject
@@ -414,8 +416,7 @@ def testGuiProjTree_MoveItemToTrash(qtbot, caplog, monkeypatch, nwGUI, projPath,
@pytest.mark.gui
def testGuiProjTree_PermanentlyDeleteItem(qtbot, caplog, monkeypatch, nwGUI, projPath, mockRnd):
"""Test permanently deleting items.
"""
"""Test permanently deleting items."""
monkeypatch.setattr(GuiEditLabel, "getLabel", lambda *a, text: (text, True))
theProject = nwGUI.theProject
@@ -466,8 +467,7 @@ def testGuiProjTree_PermanentlyDeleteItem(qtbot, caplog, monkeypatch, nwGUI, pro
@pytest.mark.gui
def testGuiProjTree_EmptyTrash(qtbot, caplog, monkeypatch, nwGUI, projPath, mockRnd):
"""Test emptying Trash.
"""
"""Test emptying Trash."""
monkeypatch.setattr(GuiEditLabel, "getLabel", lambda *a, text: (text, True))
theProject = nwGUI.theProject
@@ -637,8 +637,7 @@ def testGuiProjTree_ContextMenu(qtbot, monkeypatch, nwGUI, projPath, mockRnd):
@pytest.mark.gui
def testGuiProjTree_MergeDocuments(qtbot, monkeypatch, nwGUI, projPath, mockRnd, ipsumText):
"""Test the merge document function.
"""
"""Test the merge document function."""
mergeData = {}
monkeypatch.setattr(GuiDocMerge, "__init__", lambda *a: None)
@@ -739,8 +738,7 @@ def testGuiProjTree_MergeDocuments(qtbot, monkeypatch, nwGUI, projPath, mockRnd,
@pytest.mark.gui
def testGuiProjTree_SplitDocument(qtbot, monkeypatch, nwGUI, projPath, mockRnd, ipsumText):
"""Test the split document function.
"""
"""Test the split document function."""
splitData = {}
splitText = []
@@ -848,6 +846,63 @@ def testGuiProjTree_SplitDocument(qtbot, monkeypatch, nwGUI, projPath, mockRnd,
# END Test testGuiProjTree_SplitDocument
@pytest.mark.gui
def testGuiProjTree_Duplicate(qtbot, monkeypatch, nwGUI: GuiMain, projPath, mockRnd):
"""Test the duplicate items function."""
# Create a project
buildTestProject(nwGUI, projPath)
assert len(nwGUI.theProject.tree) == 8
projTree = nwGUI.projView.projTree
projTree._getTreeItem(C.hNovelRoot).setExpanded(True) # type: ignore
projTree._getTreeItem(C.hChapterDir).setExpanded(True) # type: ignore
# Nothing to do
assert projTree._duplicateFromHandle(C.hInvalid) is False
assert len(nwGUI.theProject.tree) == 8
# Duplicate title page, but select no
with monkeypatch.context() as mp:
mp.setattr(QMessageBox, "question", lambda *a: QMessageBox.No)
assert projTree._duplicateFromHandle(C.hTitlePage) is False
assert len(nwGUI.theProject.tree) == 8
# Duplicate title page
assert projTree._duplicateFromHandle(C.hTitlePage) is True
assert len(nwGUI.theProject.tree) == 9
# Duplicate folder
assert projTree._duplicateFromHandle(C.hChapterDir) is True
assert len(nwGUI.theProject.tree) == 12
# Duplicate novel root
assert projTree._duplicateFromHandle(C.hNovelRoot) is True
assert len(nwGUI.theProject.tree) == 21
# Check tree order that all items are next to eachother
assert nwGUI.theProject.tree._treeOrder == [
C.hNovelRoot, C.hTitlePage, "0000000000010", C.hChapterDir, C.hChapterDoc, C.hSceneDoc,
"0000000000011", "0000000000012", "0000000000013", "0000000000014", "0000000000015",
"0000000000016", "0000000000017", "0000000000018", "0000000000019", "000000000001a",
"000000000001b", "000000000001c", C.hPlotRoot, C.hCharRoot, C.hWorldRoot,
]
# Make the duplicator stop early
content = nwGUI.theProject.storage.contentPath
assert isinstance(content, Path)
(content / "000000000001e.nwd").touch()
assert (content / "000000000001e.nwd").exists()
# Should only create the folder, and skip the two files because the
# next handle is already a file
assert projTree._duplicateFromHandle(C.hChapterDir) is True
assert len(nwGUI.theProject.tree) == 22
# qtbot.stop()
# END Test testGuiProjTree_Duplicate
@pytest.mark.gui
def testGuiProjTree_Other(qtbot, monkeypatch, nwGUI, projPath, mockRnd):
"""Test various parts of the project tree class not covered by