Add duplication of files and folders (#1480)

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