diff --git a/novelwriter/core/coretools.py b/novelwriter/core/coretools.py index d669586b..fbd3abd3 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: # 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. """ 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. """ diff --git a/novelwriter/core/item.py b/novelwriter/core/item.py index dfee50a3..21383d18 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,98 +77,121 @@ class NWItem: return - def __repr__(self): + def __repr__(self) -> str: return f"" - 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 diff --git a/novelwriter/core/tree.py b/novelwriter/core/tree.py index da5686db..f8ea65b7 100644 --- a/novelwriter/core/tree.py +++ b/novelwriter/core/tree.py @@ -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 . """ +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. """ diff --git a/novelwriter/gui/projtree.py b/novelwriter/gui/projtree.py index fa31b1b1..01ef501d 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") @@ -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) 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..04aae086 100644 --- a/tests/test_core/test_core_coretools.py +++ b/tests/test_core/test_core_coretools.py @@ -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 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 diff --git a/tests/test_core/test_core_item.py b/tests/test_core/test_core_item.py index b388a548..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) @@ -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 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 diff --git a/tests/test_gui/test_gui_projtree.py b/tests/test_gui/test_gui_projtree.py index 92b68e9a..dce031f9 100644 --- a/tests/test_gui/test_gui_projtree.py +++ b/tests/test_gui/test_gui_projtree.py @@ -21,14 +21,17 @@ along with this program. If not, see . 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