From bba46ba042486fbb588334b026d7dcf997789e1e Mon Sep 17 00:00:00 2001
From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com>
Date: Thu, 20 Jul 2023 15:14:01 +0200
Subject: [PATCH 01/11] Add annotations to core tools
---
novelwriter/core/coretools.py | 99 ++++++++++++++++++-----------------
1 file changed, 51 insertions(+), 48 deletions(-)
diff --git a/novelwriter/core/coretools.py b/novelwriter/core/coretools.py
index d669586b..80e297db 100644
--- a/novelwriter/core/coretools.py
+++ b/novelwriter/core/coretools.py
@@ -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 .
"""
+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:
+ 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):
+ def newTargetDoc(self, srcHandle: str, docLabel: str) -> str | None:
"""Create a barnd 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()
@@ -265,7 +268,7 @@ class ProjectBuilder:
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 +277,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 +419,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.
"""
From fe7e44a3a6cfc8f86ab20003fc34fe14ed2975fa Mon Sep 17 00:00:00 2001
From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com>
Date: Thu, 20 Jul 2023 16:00:16 +0200
Subject: [PATCH 02/11] Add annotations to tree and item classes
---
novelwriter/core/item.py | 187 ++++++++++++++----------------
novelwriter/core/tree.py | 157 ++++++++++---------------
tests/test_core/test_core_item.py | 8 --
tests/test_core/test_core_tree.py | 10 +-
4 files changed, 150 insertions(+), 212 deletions(-)
diff --git a/novelwriter/core/item.py b/novelwriter/core/item.py
index dfee50a3..7ec29852 100644
--- a/novelwriter/core/item.py
+++ b/novelwriter/core/item.py
@@ -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 .
"""
+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,10 +77,10 @@ class NWItem:
return
- def __repr__(self):
+ def __repr__(self) -> str:
return f""
- def __bool__(self):
+ def __bool__(self) -> bool:
return self._handle is not None
##
@@ -80,87 +88,86 @@ class NWItem:
##
@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[str, dict[str, str]]:
+ """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 +197,8 @@ class NWItem:
return data
- def unpack(self, data):
- """Set the values from a data dictionary.
- """
+ def unpack(self, data: dict[str, dict[str, Any]]) -> bool:
+ """Set the values from a data dictionary."""
item = data.get("itemAttr", {})
meta = data.get("metaAttr", {})
name = data.get("nameAttr", {})
@@ -243,9 +249,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 +273,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 +289,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 +355,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 +381,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 +391,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 +399,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 +412,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 +425,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 +438,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 +472,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
diff --git a/novelwriter/core/tree.py b/novelwriter/core/tree.py
index da5686db..14501940 100644
--- a/novelwriter/core/tree.py
+++ b/novelwriter/core/tree.py
@@ -22,18 +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 .
"""
+from __future__ import annotations
import random
import logging
+from typing import TYPE_CHECKING, Any, 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 +46,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 +64,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 +74,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 +114,9 @@ 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 pack(self) -> list[dict[str, dict[str, str]]]:
+ """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 +125,24 @@ class NWTree:
tree.append(tItem.pack())
return tree
- def unpack(self, data):
+ def unpack(self, data: list[dict[str, dict[str, Any]]]) -> 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 +186,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 +206,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 +227,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 +263,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 +279,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 +295,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 +303,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 +317,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,37 +336,19 @@ 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) -> NWItem | None:
"""Return a project item based on its handle. Returns None if
the handle doesn't exist in the project.
"""
@@ -385,9 +357,8 @@ class NWTree:
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 +377,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 +393,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.
"""
diff --git a/tests/test_core/test_core_item.py b/tests/test_core/test_core_item.py
index b388a548..9fc90dad 100644
--- a/tests/test_core/test_core_item.py
+++ b/tests/test_core/test_core_item.py
@@ -262,19 +262,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
# ==============
diff --git a/tests/test_core/test_core_tree.py b/tests/test_core/test_core_tree.py
index ee9ce687..746e0539 100644
--- a/tests/test_core/test_core_tree.py
+++ b/tests/test_core/test_core_tree.py
@@ -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
From 1c5466a799c125ac3123bc03c3c04b4935fdf533 Mon Sep 17 00:00:00 2001
From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com>
Date: Thu, 20 Jul 2023 18:50:53 +0200
Subject: [PATCH 03/11] Add annotations to document class
---
novelwriter/core/document.py | 67 ++++++++++++++++++++++--------------
1 file changed, 41 insertions(+), 26 deletions(-)
diff --git a/novelwriter/core/document.py b/novelwriter/core/document.py
index d71f2309..7fee1d7d 100644
--- a/novelwriter/core/document.py
+++ b/novelwriter/core/document.py
@@ -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 .
"""
+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""
- 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.
"""
From 567045e4ff1e56fc2accaf66352bc496579f98e7 Mon Sep 17 00:00:00 2001
From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com>
Date: Thu, 20 Jul 2023 18:51:26 +0200
Subject: [PATCH 04/11] Add document duplication tool and support in item and
tree classes
---
novelwriter/core/coretools.py | 47 +++++++++++++++++++++++++++++++++--
novelwriter/core/item.py | 27 ++++++++++++++++++--
novelwriter/core/tree.py | 21 ++++++++++++----
3 files changed, 86 insertions(+), 9 deletions(-)
diff --git a/novelwriter/core/coretools.py b/novelwriter/core/coretools.py
index 80e297db..ec50d73b 100644
--- a/novelwriter/core/coretools.py
+++ b/novelwriter/core/coretools.py
@@ -40,7 +40,7 @@ from novelwriter.constants import nwItemClass
from novelwriter.core.item import NWItem
from novelwriter.core.project import NWProject
-if TYPE_CHECKING:
+if TYPE_CHECKING: # pragma: no cover
from novelwriter.guimain import GuiMain
logger = logging.getLogger(__name__)
@@ -76,7 +76,7 @@ class DocMerger:
return
def newTargetDoc(self, srcHandle: str, docLabel: str) -> str | None:
- """Create a barnd new target document based on a source handle
+ """Create a brand new target document based on a source handle
and a new doc label. Calling this function resets the class.
"""
srcItem = self._project.tree[srcHandle]
@@ -263,6 +263,49 @@ 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])
+ 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.
diff --git a/novelwriter/core/item.py b/novelwriter/core/item.py
index 7ec29852..d4764aec 100644
--- a/novelwriter/core/item.py
+++ b/novelwriter/core/item.py
@@ -83,6 +83,29 @@ class NWItem:
def __bool__(self) -> bool:
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
##
@@ -163,7 +186,7 @@ class NWItem:
# Pack/Unpack Data
##
- def pack(self) -> dict[str, dict[str, str]]:
+ def pack(self) -> dict:
"""Pack all the data in the class instance into a dictionary."""
item: dict[str, str] = {}
meta: dict[str, str] = {}
@@ -197,7 +220,7 @@ class NWItem:
return data
- def unpack(self, data: dict[str, dict[str, Any]]) -> bool:
+ def unpack(self, data: dict) -> bool:
"""Set the values from a data dictionary."""
item = data.get("itemAttr", {})
meta = data.get("metaAttr", {})
diff --git a/novelwriter/core/tree.py b/novelwriter/core/tree.py
index 14501940..f8ea65b7 100644
--- a/novelwriter/core/tree.py
+++ b/novelwriter/core/tree.py
@@ -24,10 +24,11 @@ along with this program. If not, see .
"""
from __future__ import annotations
+import copy
import random
import logging
-from typing import TYPE_CHECKING, Any, Iterator
+from typing import TYPE_CHECKING, Iterator
from pathlib import Path
from novelwriter.enum import nwItemClass, nwItemLayout, nwItemType
@@ -114,7 +115,17 @@ class NWTree:
return True
- def pack(self) -> list[dict[str, dict[str, str]]]:
+ 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.
"""
@@ -125,7 +136,7 @@ class NWTree:
tree.append(tItem.pack())
return tree
- def unpack(self, data: list[dict[str, dict[str, Any]]]) -> None:
+ def unpack(self, data: list[dict]) -> None:
"""Iterate through all items of a list and add them to the
project tree.
"""
@@ -348,11 +359,11 @@ class NWTree:
"""True if there are any items in the project."""
return bool(self._treeOrder)
- def __getitem__(self, tHandle: str) -> NWItem | None:
+ 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
From 1559e3299abe54ddd49070bd7d87858ed385e2eb Mon Sep 17 00:00:00 2001
From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com>
Date: Thu, 20 Jul 2023 18:51:50 +0200
Subject: [PATCH 05/11] Add item duplication functionality to project tree
---
novelwriter/gui/projtree.py | 66 +++++++++++++++++++++++++++++--------
1 file changed, 53 insertions(+), 13 deletions(-)
diff --git a/novelwriter/gui/projtree.py b/novelwriter/gui/projtree.py
index fa31b1b1..8d9f712d 100644
--- a/novelwriter/gui/projtree.py
+++ b/novelwriter/gui/projtree.py
@@ -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 .
"""
+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")
@@ -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,39 @@ 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):
+ print(dHandle, nHandle)
+ 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.ERROR)
+
+ 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,11 +1713,11 @@ 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):
"""Create a QTreeWidgetItem from an NWItem and add it to the
From 7a72634448d1143bce997710e882a99f5a256fb7 Mon Sep 17 00:00:00 2001
From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com>
Date: Thu, 20 Jul 2023 19:51:01 +0200
Subject: [PATCH 06/11] Improve how items are added to project tree, allowing
root folders to be added next to specified items
---
novelwriter/core/item.py | 1 +
novelwriter/gui/projtree.py | 90 ++++++++++++++---------------
tests/test_core/test_core_item.py | 79 ++++++++++++++++++++++---
tests/test_gui/test_gui_projtree.py | 6 +-
4 files changed, 121 insertions(+), 55 deletions(-)
diff --git a/novelwriter/core/item.py b/novelwriter/core/item.py
index d4764aec..21383d18 100644
--- a/novelwriter/core/item.py
+++ b/novelwriter/core/item.py
@@ -81,6 +81,7 @@ 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:
diff --git a/novelwriter/gui/projtree.py b/novelwriter/gui/projtree.py
index 8d9f712d..5ad6aaf6 100644
--- a/novelwriter/gui/projtree.py
+++ b/novelwriter/gui/projtree.py
@@ -658,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)
@@ -683,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:
@@ -723,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
@@ -738,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
@@ -746,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.
"""
@@ -761,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.
@@ -998,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
@@ -1037,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
@@ -1052,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)
@@ -1719,10 +1719,17 @@ class GuiProjectTree(QTreeWidget):
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()
@@ -1740,35 +1747,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)
@@ -1808,7 +1806,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)
diff --git a/tests/test_core/test_core_item.py b/tests/test_core/test_core_item.py
index 9fc90dad..d488de3c 100644
--- a/tests/test_core/test_core_item.py
+++ b/tests/test_core/test_core_item.py
@@ -19,6 +19,7 @@ You should have received a copy of the GNU General Public License
along with this program. If not, see .
"""
+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)
@@ -278,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
diff --git a/tests/test_gui/test_gui_projtree.py b/tests/test_gui/test_gui_projtree.py
index 92b68e9a..9309352c 100644
--- a/tests/test_gui/test_gui_projtree.py
+++ b/tests/test_gui/test_gui_projtree.py
@@ -37,8 +37,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 +158,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()
From b3c744d22ec93c5ad89e14073382f0e0cadc8ab9 Mon Sep 17 00:00:00 2001
From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com>
Date: Thu, 20 Jul 2023 20:36:09 +0200
Subject: [PATCH 07/11] Add test coverage of DocDuplicator class
---
novelwriter/core/coretools.py | 1 +
.../coreTools_DocDuplicator_nwProject.nwx | 106 +++++++++++++
tests/test_core/test_core_coretools.py | 146 +++++++++++++++++-
3 files changed, 248 insertions(+), 5 deletions(-)
create mode 100644 tests/reference/coreTools_DocDuplicator_nwProject.nwx
diff --git a/novelwriter/core/coretools.py b/novelwriter/core/coretools.py
index ec50d73b..fbd3abd3 100644
--- a/novelwriter/core/coretools.py
+++ b/novelwriter/core/coretools.py
@@ -292,6 +292,7 @@ class DocDuplicator:
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)
diff --git a/tests/reference/coreTools_DocDuplicator_nwProject.nwx b/tests/reference/coreTools_DocDuplicator_nwProject.nwx
new file mode 100644
index 00000000..0ee99397
--- /dev/null
+++ b/tests/reference/coreTools_DocDuplicator_nwProject.nwx
@@ -0,0 +1,106 @@
+
+
+
+ New Project
+ New Novel
+ Jane Doe
+
+
+ yes
+ None
+ None
+
+ None
+ None
+ None
+ None
+
+
+
+ New
+ Note
+ Draft
+ Finished
+
+
+ New
+ Minor
+ Major
+ Main
+
+
+
+ -
+
+ Novel
+
+ -
+
+ Plot
+
+ -
+
+ Characters
+
+ -
+
+ World
+
+ -
+
+ Title Page
+
+ -
+
+ New Chapter
+
+ -
+
+ New Chapter
+
+ -
+
+ New Scene
+
+ -
+
+ New Scene
+
+ -
+
+ New Chapter
+
+ -
+
+ New Chapter
+
+ -
+
+ New Scene
+
+ -
+
+ Novel
+
+ -
+
+ Title Page
+
+ -
+
+ New Chapter
+
+ -
+
+ New Chapter
+
+ -
+
+ New Scene
+
+ -
+
+ New Chapter
+
+
+
diff --git a/tests/test_core/test_core_coretools.py b/tests/test_core/test_core_coretools.py
index c28354f8..108c98a2 100644
--- a/tests/test_core/test_core_coretools.py
+++ b/tests/test_core/test_core_coretools.py
@@ -19,6 +19,7 @@ You should have received a copy of the GNU General Public License
along with this program. If not, see .
"""
+from pathlib import Path
import uuid
import pytest
@@ -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
From 188536d0f6e3f4512a952d550c2fc5cc57d1733d Mon Sep 17 00:00:00 2001
From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com>
Date: Thu, 20 Jul 2023 21:00:11 +0200
Subject: [PATCH 08/11] Add test coverage of duplicate function in project tree
---
novelwriter/gui/projtree.py | 3 +-
tests/test_gui/test_gui_projtree.py | 80 ++++++++++++++++++++++++-----
2 files changed, 67 insertions(+), 16 deletions(-)
diff --git a/novelwriter/gui/projtree.py b/novelwriter/gui/projtree.py
index 5ad6aaf6..01ef501d 100644
--- a/novelwriter/gui/projtree.py
+++ b/novelwriter/gui/projtree.py
@@ -1687,14 +1687,13 @@ class GuiProjectTree(QTreeWidget):
docDup = DocDuplicator(self.theProject)
dupCount = 0
for dHandle, nHandle in docDup.duplicate(itemTree):
- print(dHandle, nHandle)
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.ERROR)
+ self.mainGui.makeAlert(self.tr("Could not duplicate all items."), nwAlert.WARN)
self.saveTreeOrder()
diff --git a/tests/test_gui/test_gui_projtree.py b/tests/test_gui/test_gui_projtree.py
index 9309352c..3a249f25 100644
--- a/tests/test_gui/test_gui_projtree.py
+++ b/tests/test_gui/test_gui_projtree.py
@@ -19,9 +19,11 @@ You should have received a copy of the GNU General Public License
along with this program. If not, see .
"""
+from pathlib import Path
import pytest
from mocked import causeOSError
+from novelwriter.guimain import GuiMain
from tools import C, buildTestProject
from PyQt5.QtCore import Qt
@@ -170,8 +172,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
@@ -280,8 +281,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
@@ -363,8 +363,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
@@ -416,8 +415,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
@@ -468,8 +466,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
@@ -639,8 +636,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)
@@ -741,8 +737,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 = []
@@ -850,6 +845,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
From 75a840b10b4d8110807b6e7c67bc0410987b1d76 Mon Sep 17 00:00:00 2001
From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com>
Date: Thu, 20 Jul 2023 21:07:12 +0200
Subject: [PATCH 09/11] Clean up imports
---
tests/test_core/test_core_coretools.py | 2 +-
tests/test_gui/test_gui_projtree.py | 4 ++--
2 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/tests/test_core/test_core_coretools.py b/tests/test_core/test_core_coretools.py
index 108c98a2..04aae086 100644
--- a/tests/test_core/test_core_coretools.py
+++ b/tests/test_core/test_core_coretools.py
@@ -19,11 +19,11 @@ You should have received a copy of the GNU General Public License
along with this program. If not, see .
"""
-from pathlib import Path
import uuid
import pytest
from shutil import copyfile
+from pathlib import Path
from zipfile import ZipFile
from mocked import causeOSError
diff --git a/tests/test_gui/test_gui_projtree.py b/tests/test_gui/test_gui_projtree.py
index 3a249f25..36dbbaa3 100644
--- a/tests/test_gui/test_gui_projtree.py
+++ b/tests/test_gui/test_gui_projtree.py
@@ -22,15 +22,15 @@ along with this program. If not, see .
from pathlib import Path
import pytest
-from mocked import causeOSError
-from novelwriter.guimain import GuiMain
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
From def846ce4b98fde1fa7c8025cd0ea7d123ad954c Mon Sep 17 00:00:00 2001
From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com>
Date: Thu, 20 Jul 2023 21:08:38 +0200
Subject: [PATCH 10/11] Clean up imports (missed one)
---
tests/test_gui/test_gui_projtree.py | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/tests/test_gui/test_gui_projtree.py b/tests/test_gui/test_gui_projtree.py
index 36dbbaa3..dce031f9 100644
--- a/tests/test_gui/test_gui_projtree.py
+++ b/tests/test_gui/test_gui_projtree.py
@@ -19,9 +19,10 @@ You should have received a copy of the GNU General Public License
along with this program. If not, see .
"""
-from pathlib import Path
import pytest
+from pathlib import Path
+
from tools import C, buildTestProject
from mocked import causeOSError
From 4e448134fcba113313b4790765296b21b954a8fb Mon Sep 17 00:00:00 2001
From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com>
Date: Thu, 20 Jul 2023 21:14:10 +0200
Subject: [PATCH 11/11] Add test coverage of new function in Document class
---
tests/test_core/test_core_document.py | 8 ++++++--
1 file changed, 6 insertions(+), 2 deletions(-)
diff --git a/tests/test_core/test_core_document.py b/tests/test_core/test_core_document.py
index 319e2ef8..9ab3a495 100644
--- a/tests/test_core/test_core_document.py
+++ b/tests/test_core/test_core_document.py
@@ -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