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