Rewrite project tree as a model/view set (#2119)

This commit is contained in:
Veronica Berglyd Olsen
2024-11-25 00:11:53 +01:00
committed by GitHub
65 changed files with 4193 additions and 3737 deletions
+6 -1
View File
@@ -37,7 +37,7 @@ from typing import TYPE_CHECKING, Any, Literal, TypeVar
from urllib.parse import urljoin
from urllib.request import pathname2url
from PyQt5.QtCore import QCoreApplication, QUrl
from PyQt5.QtCore import QCoreApplication, QMimeData, QUrl
from PyQt5.QtGui import QColor, QDesktopServices, QFont, QFontInfo
from novelwriter.constants import nwConst, nwLabels, nwUnicode, trConst
@@ -441,6 +441,11 @@ def qtLambda(func: Callable, *args: Any, **kwargs: Any) -> Callable:
return wrapper
def decodeMimeHandles(mimeData: QMimeData) -> list[str]:
"""Decode and split a mime data object with handles."""
return mimeData.data(nwConst.MIME_HANDLE).data().decode().split("|")
##
# Encoder Functions
##
+7
View File
@@ -53,6 +53,9 @@ class nwConst:
# Requests
USER_AGENT = "Mozilla/5.0 (compatible; novelWriter (Python))"
# Mime Types
MIME_HANDLE = "text/vnd.novelwriter.handle"
# Gui Settings
STATUS_MSG_TIMEOUT = 15000 # milliseconds
MAX_SEARCH_RESULT = 1000
@@ -271,6 +274,10 @@ class nwLabels:
"doc_h4": QT_TRANSLATE_NOOP("Constant", "Novel Section"),
"note": QT_TRANSLATE_NOOP("Constant", "Project Note"),
}
ACTIVE_NAME = {
"checked": QT_TRANSLATE_NOOP("Constant", "Active"),
"unchecked": QT_TRANSLATE_NOOP("Constant", "Inactive"),
}
KEY_NAME = {
nwKeyWords.TAG_KEY: QT_TRANSLATE_NOOP("Constant", "Tag"),
nwKeyWords.POV_KEY: QT_TRANSLATE_NOOP("Constant", "Point of View"),
+104 -118
View File
@@ -56,10 +56,17 @@ class DocMerger:
def __init__(self, project: NWProject) -> None:
self._project = project
self._error = ""
self._targetDoc = None
self._targetText = []
self._target = None
self._text = []
return
@property
def targetHandle(self) -> str | None:
"""Get the handle of the target document."""
if self._target:
return self._target.itemHandle
return None
##
# Methods
##
@@ -72,63 +79,56 @@ class DocMerger:
"""Set the target document for the merging. Calling this
function resets the class.
"""
self._targetDoc = tHandle
self._targetText = []
self._target = self._project.tree[tHandle]
self._text = []
return
def newTargetDoc(self, srcHandle: str, docLabel: str) -> str | None:
def newTargetDoc(self, sHandle: str, label: 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._project.tree[srcHandle]
if srcItem is None or srcItem.itemParent is None:
return None
sItem = self._project.tree[sHandle]
if sItem and sItem.itemParent:
tHandle = self._project.newFile(label, sItem.itemParent)
if nwItem := self._project.tree[tHandle]:
nwItem.setLayout(sItem.itemLayout)
nwItem.setStatus(sItem.itemStatus)
nwItem.setImport(sItem.itemImport)
nwItem.notifyToRefresh()
self._target = nwItem
self._text = []
return
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: str, addComment: bool, cmtPrefix: str) -> bool:
def appendText(self, sHandle: str, addComment: bool, cmtPrefix: str) -> None:
"""Append text from an existing document to the text buffer."""
srcItem = self._project.tree[srcHandle]
if srcItem is None:
return False
docText = self._project.storage.getDocumentText(srcHandle).rstrip("\n")
if addComment:
docInfo = srcItem.describeMe()
docSt, _ = srcItem.getImportStatus()
cmtLine = f"% {cmtPrefix} {docInfo}: {srcItem.itemName} [{docSt}]\n\n"
docText = cmtLine + docText
self._targetText.append(docText)
return True
if item := self._project.tree[sHandle]:
text = self._project.storage.getDocumentText(sHandle).rstrip("\n")
if addComment:
info = item.describeMe()
status, _ = item.getImportStatus()
text = f"% {cmtPrefix} {info}: {item.itemName} [{status}]\n\n{text}"
self._text.append(text)
return
def writeTargetDoc(self) -> bool:
"""Write the accumulated text into the designated target
document, appending any existing text.
"""
if self._targetDoc is None:
return False
if self._target:
outDoc = self._project.storage.getDocument(self._target.itemHandle)
if text := (outDoc.readDocument() or "").rstrip("\n"):
self._text.insert(0, text)
outDoc = self._project.storage.getDocument(self._targetDoc)
if text := (outDoc.readDocument() or "").rstrip("\n"):
self._targetText.insert(0, text)
status = outDoc.writeDocument("\n\n".join(self._text) + "\n\n")
if not status:
self._error = outDoc.getError()
status = outDoc.writeDocument("\n\n".join(self._targetText) + "\n\n")
if not status:
self._error = outDoc.getError()
self._project.index.reIndexHandle(self._target.itemHandle)
self._target.notifyToRefresh()
return status
return status
return False
class DocSplitter:
@@ -172,23 +172,19 @@ class DocSplitter:
self._inFolder = False
return
def newParentFolder(self, pHandle: str, folderLabel: str) -> str | None:
def newParentFolder(self, pHandle: str, folderLabel: 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._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
if self._srcItem:
nHandle = self._project.newFolder(folderLabel, pHandle)
if nwItem := self._project.tree[nHandle]:
nwItem.setStatus(self._srcItem.itemStatus)
nwItem.setImport(self._srcItem.itemImport)
nwItem.notifyToRefresh()
self._parHandle = nHandle
self._inFolder = True
return
def splitDocument(self, splitData: list, splitText: list[str]) -> None:
"""Loop through the split data record and perform the split job
@@ -202,58 +198,50 @@ class DocSplitter:
self._rawData.insert(0, (chunk, hLevel, hLabel))
return
def writeDocuments(self, docHierarchy: bool) -> Iterable[tuple[bool, str | None, str | None]]:
def writeDocuments(self, docHierarchy: bool) -> Iterable[bool]:
"""An iterator that will write each document in the buffer, and
return its new handle, parent handle, and sibling handle.
"""
if self._srcHandle is None or self._srcItem is None or self._parHandle is None:
return
if self._srcHandle and self._srcItem and self._parHandle:
pHandle = self._parHandle
hHandle = [self._parHandle, None, None, None, None]
pLevel = 0
for docText, hLevel, docLabel in self._rawData:
pHandle = self._parHandle
nHandle = self._parHandle if self._inFolder else self._srcHandle
hHandle = [self._parHandle, None, None, None, None]
hLevel = minmax(hLevel, 1, 4)
if pLevel == 0:
pLevel = hLevel
pLevel = 0
for docText, hLevel, docLabel in self._rawData:
if docHierarchy:
if hLevel == 1:
pHandle = self._parHandle
elif hLevel == 2:
pHandle = hHandle[1] or hHandle[0]
elif hLevel == 3:
pHandle = hHandle[2] or hHandle[1] or hHandle[0]
elif hLevel == 4:
pHandle = hHandle[3] or hHandle[2] or hHandle[1] or hHandle[0]
hLevel = minmax(hLevel, 1, 4)
if pLevel == 0:
pLevel = hLevel
if (
(dHandle := self._project.newFile(docLabel, pHandle))
and (nwItem := self._project.tree[dHandle])
):
hHandle[hLevel] = dHandle
nwItem.setStatus(self._srcItem.itemStatus)
nwItem.setImport(self._srcItem.itemImport)
if docHierarchy:
if hLevel == 1:
pHandle = self._parHandle
elif hLevel == 2:
pHandle = hHandle[1] or hHandle[0]
elif hLevel == 3:
pHandle = hHandle[2] or hHandle[1] or hHandle[0]
elif hLevel == 4:
pHandle = hHandle[3] or hHandle[2] or hHandle[1] or hHandle[0]
outDoc = self._project.storage.getDocument(dHandle)
status = outDoc.writeDocument("\n".join(docText))
if not status:
self._error = outDoc.getError()
if hLevel < pLevel:
nHandle = hHandle[hLevel] or hHandle[0]
elif hLevel > pLevel:
nHandle = pHandle
self._project.index.reIndexHandle(dHandle)
nwItem.notifyToRefresh()
dHandle = self._project.newFile(docLabel, pHandle)
hHandle[hLevel] = dHandle
newItem = self._project.tree[dHandle]
if isinstance(newItem, NWItem):
newItem.setStatus(self._srcItem.itemStatus)
newItem.setImport(self._srcItem.itemImport)
outDoc = self._project.storage.getDocument(dHandle)
status = outDoc.writeDocument("\n".join(docText))
if not status:
self._error = outDoc.getError()
yield status, dHandle, nHandle
hHandle[hLevel] = dHandle
nHandle = dHandle
pLevel = hLevel
yield status
hHandle[hLevel] = dHandle
pLevel = hLevel
return
@@ -270,29 +258,27 @@ class DocDuplicator:
# Methods
##
def duplicate(self, items: list[str]) -> Iterable[tuple[str, str | None]]:
def duplicate(self, items: list[str]) -> list[str]:
"""Run through a list of items, duplicate them, and copy the
text content if they are documents.
"""
result = []
after = True
if items:
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:
return
hMap[tHandle] = newItem.itemHandle
if newItem.itemParent in hMap:
newItem.setParent(hMap[newItem.itemParent])
self._project.tree.updateItemData(newItem.itemHandle)
if newItem.isFileType():
newDoc = self._project.storage.getDocument(newItem.itemHandle)
if newDoc.fileExists():
return
newDoc.writeDocument(self._project.storage.getDocumentText(tHandle))
yield newItem.itemHandle, nHandle
nHandle = None
return
if oldItem := self._project.tree[tHandle]:
pHandle = hMap.get(oldItem.itemParent or "") or oldItem.itemParent
if newItem := self._project.tree.duplicate(tHandle, pHandle, after):
hMap[tHandle] = newItem.itemHandle
if newItem.isFileType():
self._project.copyFileContent(newItem.itemHandle, tHandle)
newItem.notifyToRefresh()
result.append(newItem.itemHandle)
after = False
else:
break
return result
class DocSearch:
@@ -523,7 +509,7 @@ class ProjectBuilder:
# Also add the archive and trash folders
project.newRoot(nwItemClass.ARCHIVE)
project.trashFolder()
project.tree.trash # Triggers the creation of Trash
project.saveProject()
project.closeProject()
+9 -19
View File
@@ -116,24 +116,24 @@ class NWIndex:
# Public Methods
##
def clearIndex(self) -> None:
def clear(self) -> None:
"""Clear the index dictionaries and time stamps."""
self._tagsIndex.clear()
self._itemIndex.clear()
self._indexChange = 0.0
self._rootChange = {}
SHARED.indexSignalProxy({"event": "clearIndex"})
SHARED.emitIndexCleared(self._project)
return
def rebuildIndex(self) -> None:
def rebuild(self) -> None:
"""Rebuild the entire index from scratch."""
self.clearIndex()
self.clear()
for nwItem in self._project.tree:
if nwItem.isFileType():
text = self._project.storage.getDocumentText(nwItem.itemHandle)
self.scanText(nwItem.itemHandle, text, blockSignal=True)
self._indexBroken = False
SHARED.indexSignalProxy({"event": "buildIndex"})
SHARED.emitIndexAvailable(self._project)
return
def deleteHandle(self, tHandle: str) -> None:
@@ -143,10 +143,7 @@ class NWIndex:
for tTag in delTags:
del self._tagsIndex[tTag]
del self._itemIndex[tHandle]
SHARED.indexSignalProxy({
"event": "updateTags",
"deleted": delTags,
})
SHARED.emitIndexChangedTags(self._project, [], delTags)
return
def reIndexHandle(self, tHandle: str | None) -> None:
@@ -212,7 +209,7 @@ class NWIndex:
self.reIndexHandle(fHandle)
self._indexChange = time()
SHARED.indexSignalProxy({"event": "buildIndex"})
SHARED.emitIndexAvailable(self._project)
logger.debug("Index loaded in %.3f ms", (time() - tStart)*1000)
@@ -296,10 +293,7 @@ class NWIndex:
self._indexChange = nowTime
self._rootChange[tItem.itemRoot] = nowTime
if not blockSignal:
SHARED.indexSignalProxy({
"event": "scanText",
"handle": tHandle,
})
tItem.notifyToRefresh()
return True
@@ -370,11 +364,7 @@ class NWIndex:
del self._tagsIndex[tTag]
deleted.append(tTag)
if updated or deleted:
SHARED.indexSignalProxy({
"event": "updateTags",
"updated": updated,
"deleted": deleted,
})
SHARED.emitIndexChangedTags(self._project, updated, deleted)
return
+39 -6
View File
@@ -27,8 +27,9 @@ import logging
from typing import TYPE_CHECKING, Any
from PyQt5.QtGui import QIcon
from PyQt5.QtGui import QFont, QIcon
from novelwriter import CONFIG, SHARED
from novelwriter.common import (
checkInt, isHandle, isItemClass, isItemLayout, isItemType, simplified,
yesNo
@@ -256,6 +257,8 @@ class NWItem:
self._paraCount = 0
self._cursorPos = 0
self._initCount = self._wordCount
return True
@classmethod
@@ -281,6 +284,15 @@ class NWItem:
cls._initCount = source._initCount
return cls
##
# Action Methods
##
def notifyToRefresh(self) -> None:
"""Notify GUI that item info needs to be refreshed."""
self._project.tree.refreshItems([self._handle])
return
##
# Lookup Methods
##
@@ -309,6 +321,19 @@ class NWItem:
return trConst(nwLabels.ITEM_DESCRIPTION.get(descKey, ""))
def getMainIcon(self) -> QIcon:
"""Get the main item icon."""
return SHARED.theme.getItemIcon(self._type, self._class, self._layout, self._heading)
def getMainFont(self) -> QFont:
"""Get the main item icon."""
if CONFIG.emphLabels and self._layout == nwItemLayout.DOCUMENT:
if self._heading == "H1":
return SHARED.theme.guiFontBU
elif self._heading == "H2":
return SHARED.theme.guiFontB
return SHARED.theme.guiFont
def getImportStatus(self) -> tuple[str, QIcon]:
"""Return the relevant importance or status label and icon for
the current item based on its class.
@@ -319,6 +344,19 @@ class NWItem:
entry = self._project.data.itemImport[self._import]
return entry.name, entry.icon
def getActiveStatus(self) -> tuple[str, QIcon]:
"""Return the relevant active status label and icon for
the current item based on its type.
"""
if self.isFileType():
key = "checked" if self._active else "unchecked"
text = trConst(nwLabels.ACTIVE_NAME[key])
icon = SHARED.theme.getIcon(key)
else:
text = ""
icon = SHARED.theme.getIcon("noncheckable")
return text, icon
##
# Checker Methods
##
@@ -553,8 +591,3 @@ class NWItem:
else:
self._cursorPos = 0
return
def saveInitialCount(self) -> None:
"""Save the initial word count."""
self._initCount = self._wordCount
return
+518
View File
@@ -0,0 +1,518 @@
"""
novelWriter Project Item Model
================================
File History:
Created: 2024-11-16 [2.6b2] ProjectNode
Created: 2024-11-16 [2.6b2] ProjectModel
This file is a part of novelWriter
Copyright 20182024, Veronica Berglyd Olsen
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful, but
WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
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 PyQt5.QtCore import QAbstractItemModel, QMimeData, QModelIndex, Qt
from PyQt5.QtGui import QFont, QIcon
from novelwriter.common import decodeMimeHandles, minmax
from novelwriter.constants import nwConst
from novelwriter.core.item import NWItem
from novelwriter.enum import nwItemClass
from novelwriter.types import QtAlignRight
if TYPE_CHECKING: # pragma: no cover
from novelwriter.core.tree import NWTree
logger = logging.getLogger(__name__)
INV_ROOT = "invisibleRoot"
C_FACTOR = 0x0100
C_LABEL_TEXT = 0x0000 | Qt.ItemDataRole.DisplayRole
C_LABEL_ICON = 0x0000 | Qt.ItemDataRole.DecorationRole
C_LABEL_FONT = 0x0000 | Qt.ItemDataRole.FontRole
C_COUNT_TEXT = 0x0100 | Qt.ItemDataRole.DisplayRole
C_COUNT_ICON = 0x0100 | Qt.ItemDataRole.DecorationRole
C_COUNT_ALIGN = 0x0100 | Qt.ItemDataRole.TextAlignmentRole
C_ACTIVE_ICON = 0x0200 | Qt.ItemDataRole.DecorationRole
C_ACTIVE_TIP = 0x0200 | Qt.ItemDataRole.ToolTipRole
C_STATUS_ICON = 0x0300 | Qt.ItemDataRole.DecorationRole
C_STATUS_TIP = 0x0300 | Qt.ItemDataRole.ToolTipRole
NODE_FLAGS = Qt.ItemFlag.ItemIsEnabled
NODE_FLAGS |= Qt.ItemFlag.ItemIsSelectable
NODE_FLAGS |= Qt.ItemFlag.ItemIsDropEnabled
if TYPE_CHECKING: # pragma: no cover
# Requires Python 3.10
T_NodeData = str | QIcon | QFont | Qt.AlignmentFlag | None
class ProjectNode:
"""Core: Project Model Node Class
The project tree structure is saved as nodes in a tree, starting
from a root node. This class makes up these nodes.
Each node is a wrapper around an NWItem object. The NWItem is the
object representing a single item in the project, and it only
contains a reference to its parent as well as it top level root, but
is itself not structured in a hierarchy in memory.
This class provides the necessary hierarchical structure, as well as
the data entries needed for populating the GUI project tree. It also
handles pushing and pulling information from its NWItem when
necessary.
The data to be displayed could in principle be pulled from the
NWItem whenever it is needed, but for performance reason it is
cached, as the GUI will pull this information often.
"""
C_NAME = 0
C_COUNT = 1
C_ACTIVE = 2
C_STATUS = 3
__slots__ = ("_item", "_children", "_parent", "_row", "_cache", "_flags", "_count")
def __init__(self, item: NWItem) -> None:
self._item = item
self._children: list[ProjectNode] = []
self._parent: ProjectNode | None = None
self._row = 0
self._cache: dict[int, T_NodeData] = {}
self._flags = NODE_FLAGS
self._count = 0
self.refresh()
self.updateCount()
return
def __repr__(self) -> str:
return (
f"<ProjectNode handle={self._item.itemHandle} "
f"parent={self._parent.item.itemHandle if self._parent else None} "
f"row={self._row} "
f"children={len(self._children)}>"
)
def __bool__(self) -> bool:
"""A node should always evaluate to True."""
return True
##
# Properties
##
@property
def item(self) -> NWItem:
"""The project item of the node."""
return self._item
@property
def children(self) -> list[ProjectNode]:
"""All children of the node."""
return self._children
@property
def count(self) -> int:
"""The count of the node."""
return self._count
##
# Data Maintenance
##
def refresh(self) -> None:
"""Refresh data values."""
# Label
self._cache[C_LABEL_ICON] = self._item.getMainIcon()
self._cache[C_LABEL_TEXT] = self._item.itemName
self._cache[C_LABEL_FONT] = self._item.getMainFont()
# Count
self._cache[C_COUNT_ALIGN] = QtAlignRight
# Active
aText, aIcon = self._item.getActiveStatus()
self._cache[C_ACTIVE_TIP] = aText
self._cache[C_ACTIVE_ICON] = aIcon
# Status
sText, sIcon = self._item.getImportStatus()
self._cache[C_STATUS_TIP] = sText
self._cache[C_STATUS_ICON] = sIcon
return
def updateCount(self, propagate: bool = True) -> None:
"""Update counts, and propagate upwards in the tree."""
self._count = self._item.wordCount + sum(c._count for c in self._children)
self._cache[C_COUNT_TEXT] = f"{self._count:n}"
if propagate and (parent := self._parent):
parent.updateCount()
return
##
# Data Access
##
def row(self) -> int:
"""Return the node's row number."""
return self._row
def childCount(self) -> int:
"""Return the number of children of the node."""
return len(self._children)
def data(self, column: int, role: Qt.ItemDataRole) -> T_NodeData:
"""Return cached node data."""
return self._cache.get(C_FACTOR*column | role)
def flags(self) -> Qt.ItemFlag:
"""Return cached node flags."""
return self._flags
def parent(self) -> ProjectNode | None:
"""Return the parent of the node."""
return self._parent
def child(self, row: int) -> ProjectNode | None:
"""Return a child ofg the node."""
if 0 <= row < len(self._children):
return self._children[row]
return None
def allChildren(self) -> list[ProjectNode]:
"""Return a recursive list of all children."""
nodes: list[ProjectNode] = []
self._recursiveAppendChildren(nodes)
return nodes
##
# Data Edit
##
def addChild(self, child: ProjectNode, pos: int = -1) -> None:
"""Add a child item to this item."""
child._parent = self
self._updateRelationships(child)
if 0 <= pos < len(self._children):
self._children.insert(pos, child)
else:
child._row = len(self._children)
self._children.append(child)
self._refreshChildrenPos()
return
def takeChild(self, pos: int) -> ProjectNode | None:
"""Remove a child item and return it."""
if 0 <= pos < len(self._children):
node = self._children.pop(pos)
self._refreshChildrenPos()
self.updateCount()
return node
return None
def moveChild(self, source: int, target: int) -> None:
"""Move a child internally."""
count = len(self._children)
if (source != target) and (0 <= source < count) and (0 <= target <= count):
node = self._children.pop(source)
self._children.insert(target, node)
self._refreshChildrenPos()
return
def setExpanded(self, state: bool) -> None:
"""Set the node's expanded state."""
if state and self._children:
self._item.setExpanded(True)
else:
self._item.setExpanded(False)
return
##
# Internal Functions
##
def _recursiveAppendChildren(self, children: list[ProjectNode]) -> None:
"""Recursively add all nodes to a list."""
for node in self._children:
children.append(node)
node._recursiveAppendChildren(children)
return
def _refreshChildrenPos(self) -> None:
"""Update the row value on all children."""
for n, child in enumerate(self._children):
child._row = n
child.item.setOrder(n)
return
def _updateRelationships(self, child: ProjectNode) -> None:
"""Update a child item's relationships."""
if self._parent:
child.item.setParent(self._item.itemHandle)
child.item.setRoot(self._item.itemRoot)
child.item.setClassDefaults(self._item.itemClass)
child._flags = NODE_FLAGS | Qt.ItemFlag.ItemIsDragEnabled
else:
child.item.setParent(None)
child.item.setRoot(child.item.itemHandle)
child.item.setClassDefaults(child.item.itemClass)
return
class ProjectModel(QAbstractItemModel):
"""Core: Project Model Class
This class provides the interface for the tree widget used on the
GUI. It implements the QModelIndex based interface required, adds
support for drag and drop, and a few other novelWriter-specific
methods needed primarily by the project tree GUI component.
"""
__slots__ = ("_tree", "_root")
def __init__(self, tree: NWTree) -> None:
super().__init__()
self._tree = tree
self._root = ProjectNode(NWItem(tree._project, INV_ROOT))
self._root.item.setName("Invisible Root")
logger.debug("Ready: ProjectModel")
return
def __del__(self) -> None: # pragma: no cover
logger.debug("Delete: ProjectModel")
return
##
# Properties
##
@property
def root(self) -> ProjectNode:
"""Return the model root item."""
return self._root
##
# Model Interface
##
def rowCount(self, index: QModelIndex) -> int:
"""Return the number of rows for an entry."""
if index.isValid():
return index.internalPointer().childCount()
return self._root.childCount()
def columnCount(self, index: QModelIndex) -> int:
"""Return the number of columns for an entry."""
return 4
def parent(self, index: QModelIndex) -> QModelIndex:
"""Get the parent model index of another index."""
if index.isValid() and (parent := index.internalPointer().parent()):
return self.createIndex(parent.row(), 0, parent)
return QModelIndex()
def index(self, row: int, column: int, parent: QModelIndex = QModelIndex()) -> QModelIndex:
"""get the index of a child item of a parent."""
if self.hasIndex(row, column, parent):
node: ProjectNode = parent.internalPointer() if parent.isValid() else self._root
if child := node.child(row):
return self.createIndex(row, column, child)
return QModelIndex()
def data(self, index: QModelIndex, role: Qt.ItemDataRole) -> T_NodeData:
"""Return display data for a project node."""
if index.isValid():
return index.internalPointer().data(index.column(), role)
return None
def flags(self, index: QModelIndex) -> Qt.ItemFlag:
"""Return flags for a project node."""
if index.isValid():
return index.internalPointer().flags()
return Qt.ItemFlag.NoItemFlags
##
# Drag and Drop
##
def supportedDropActions(self) -> Qt.DropAction:
"""Return supported drop actions"""
return Qt.DropAction.MoveAction
def mimeTypes(self) -> list[str]:
"""Return the supported mime types of the model."""
return [nwConst.MIME_HANDLE]
def mimeData(self, indices: list[QModelIndex]) -> QMimeData:
"""Encode mime data about a selection."""
handles = [
i.internalPointer().item.itemHandle.encode()
for i in indices if i.isValid() and i.column() == 0
]
mime = QMimeData()
mime.setData(nwConst.MIME_HANDLE, b"|".join(handles))
return mime
def canDropMimeData(
self, data: QMimeData, action: Qt.DropAction,
row: int, column: int, parent: QModelIndex
) -> bool:
"""Check if mime data can be dropped on the current location."""
return data.hasFormat(nwConst.MIME_HANDLE) and action == Qt.DropAction.MoveAction
def dropMimeData(
self, data: QMimeData, action: Qt.DropAction,
row: int, column: int, parent: QModelIndex
) -> bool:
"""Process mime data drop."""
if self.canDropMimeData(data, action, row, column, parent):
items = []
for handle in decodeMimeHandles(data):
if (index := self.indexFromHandle(handle)).isValid():
items.append(index)
self.multiMove(items, parent, row)
return True
return False
##
# Data Access
##
def row(self, index: QModelIndex) -> int:
"""Return the row number of the index."""
if index.isValid():
return index.internalPointer().row()
return -1
def node(self, index: QModelIndex) -> ProjectNode | None:
"""Return the node for a given model index."""
if index.isValid():
return index.internalPointer()
return None
def nodes(self, indices: list[QModelIndex]) -> list[ProjectNode]:
"""Return the nodes for a list of model indices."""
return [i.internalPointer() for i in indices if i.isValid() and i.column() == 0]
def indexFromHandle(self, handle: str | None) -> QModelIndex:
"""Get the index representing a node in the model."""
if handle and (node := self._tree.nodes.get(handle)):
return self.createIndex(node.row(), 0, node)
return QModelIndex()
def indexFromNode(self, node: ProjectNode, column: int = 0) -> QModelIndex:
"""Get the index representing a node in the model."""
return self.createIndex(node.row(), column, node)
##
# Model Edit
##
def insertChild(self, child: ProjectNode, parent: QModelIndex, pos: int) -> None:
"""Insert a node into the model at a given position."""
node: ProjectNode = parent.internalPointer() if parent.isValid() else self._root
count = node.childCount()
row = minmax(pos, 0, count) if pos >= 0 else count
self.beginInsertRows(parent, row, row)
node.addChild(child, row)
self.endInsertRows()
return
def removeChild(self, parent: QModelIndex, pos: int) -> ProjectNode | None:
"""Remove a node from the model and return it."""
node: ProjectNode = parent.internalPointer() if parent.isValid() else self._root
if 0 <= pos < node.childCount():
self.beginRemoveRows(parent, pos, pos)
child = node.takeChild(pos)
self.endRemoveRows()
return child
return None
def internalMove(self, index: QModelIndex, step: int) -> None:
"""Move an item internally among its siblings."""
if index.isValid():
node: ProjectNode = index.internalPointer()
if parent := node.parent():
pos = index.row()
new = minmax(pos + step, 0, parent.childCount() - 1)
if new != pos:
end = new if new < pos else new + 1
self.beginMoveRows(index.parent(), pos, pos, index.parent(), end)
parent.moveChild(pos, new)
self.endMoveRows()
return
def multiMove(self, indices: list[QModelIndex], target: QModelIndex, pos: int = -1) -> None:
"""Move multiple items to a new location."""
if target.isValid():
# This is a two pass process. First we only select unique
# non-root items for move, then we do a second pass and only
# move those items that don't have a parent also scheduled
# for moving or have already been moved. Child items are
# moved with the parent.
pruned = []
handles = set()
for index in indices:
if index.isValid():
node: ProjectNode = index.internalPointer()
handle = node.item.itemHandle
if node.item.isRootType() is False and handle not in handles:
pruned.append(node)
handles.add(handle)
for node in (reversed(pruned) if pos >= 0 else pruned):
if node.item.itemParent not in handles:
index = self.indexFromNode(node)
if temp := self.removeChild(index.parent(), index.row()):
self.insertChild(temp, target, pos)
for child in reversed(node.allChildren()):
node._updateRelationships(child)
child.item.notifyToRefresh()
node.item.notifyToRefresh()
return
##
# Other Methods
##
def clear(self) -> None:
"""Clear the project model."""
self._root._children.clear()
return
def allExpanded(self) -> list[QModelIndex]:
"""Return a list of all expanded items."""
expanded = []
for node in self._root.allChildren():
if node._item.isExpanded:
expanded.append(self.createIndex(node.row(), 0, node))
return expanded
def trashSelection(self, indices: list[QModelIndex]) -> bool:
"""Check if a selection of indices are all in trash or not."""
for index in indices:
if index.isValid():
node: ProjectNode = index.internalPointer()
if node.item.itemClass != nwItemClass.TRASH:
return False
return True
+67 -89
View File
@@ -26,7 +26,6 @@ from __future__ import annotations
import json
import logging
from collections.abc import Iterable
from enum import Enum
from functools import partial
from pathlib import Path
@@ -52,7 +51,8 @@ from novelwriter.enum import nwItemClass, nwItemLayout, nwItemType
from novelwriter.error import logException
if TYPE_CHECKING: # pragma: no cover
from novelwriter.core.item import NWItem
# Requires Python 3.10
from novelwriter.core.status import T_StatusKind, T_UpdateEntry
logger = logging.getLogger(__name__)
@@ -67,6 +67,11 @@ class NWProjectState(Enum):
class NWProject:
__slots__ = (
"_options", "_storage", "_data", "_tree", "_index", "_session",
"_langData", "_changed", "_valid", "_state", "tr",
)
def __init__(self) -> None:
# Core Elements
@@ -94,6 +99,12 @@ class NWProject:
logger.debug("Delete: NWProject")
return
def clear(self) -> None:
"""Clear the project."""
self._tree.clear()
self._index.clear()
return
##
# Properties
##
@@ -150,24 +161,46 @@ class NWProject:
"""Return total edit time, including the current session."""
return self._data.editTime + round(time() - self._session.start)
@property
def currentTotalCount(self) -> int:
"""Return the current total word count from the tree."""
return self._tree.model.root.count
##
# Item Methods
##
def newRoot(self, itemClass: nwItemClass, label: str | None = None) -> str:
def newRoot(self, itemClass: nwItemClass, pos: int = -1) -> str:
"""Add a new root folder to the project. If label is not set,
use the class label.
"""
label = label or trConst(nwLabels.CLASS_NAME[itemClass])
return self._tree.create(label, None, nwItemType.ROOT, itemClass)
label = trConst(nwLabels.CLASS_NAME[itemClass])
return self._tree.create(label, None, nwItemType.ROOT, itemClass=itemClass, pos=pos)
def newFolder(self, label: str, parent: str) -> str | None:
def newFolder(self, label: str, parent: str, pos: int = -1) -> str | None:
"""Add a new folder with a given label and parent item."""
return self._tree.create(label, parent, nwItemType.FOLDER)
return self._tree.create(label, parent, nwItemType.FOLDER, pos=pos)
def newFile(self, label: str, parent: str) -> str | None:
def newFile(self, label: str, parent: str, pos: int = -1) -> str | None:
"""Add a new file with a given label and parent item."""
return self._tree.create(label, parent, nwItemType.FILE)
return self._tree.create(label, parent, nwItemType.FILE, pos=pos)
def removeItem(self, tHandle: str) -> bool:
"""Remove an item from the project. This will delete both the
project entry and a document file if it exists.
"""
if self._tree.checkType(tHandle, nwItemType.FILE):
SHARED.closeDocument(tHandle)
doc = self._storage.getDocument(tHandle)
if not doc.deleteDocument():
SHARED.error(
self.tr("Could not delete document file."),
info=doc.getError()
)
return False
self._index.deleteHandle(tHandle)
self._tree.remove(tHandle)
return True
def writeNewFile(self, tHandle: str, hLevel: int, isDocument: bool, text: str = "") -> bool:
"""Write content to a new document after it is created. This
@@ -209,35 +242,21 @@ class NWProject:
text = self._storage.getDocumentText(sHandle)
self._storage.getDocument(tHandle).writeDocument(text)
sItem.setLayout(tItem.itemLayout)
self._index.scanText(tHandle, text)
self._index.reIndexHandle(tHandle)
return True
def removeItem(self, tHandle: str) -> bool:
"""Remove an item from the project. This will delete both the
project entry and a document file if it exists.
def createNewNote(self, tag: str, itemClass: nwItemClass) -> None:
"""Create a new note. This function is used by the document
editor to create note files for unknown tags.
"""
if self._tree.checkType(tHandle, nwItemType.FILE):
delDoc = self._storage.getDocument(tHandle)
if not delDoc.deleteDocument():
SHARED.error(
self.tr("Could not delete document file."),
info=delDoc.getError()
)
return False
self._index.deleteHandle(tHandle)
del self._tree[tHandle]
return True
def trashFolder(self) -> str:
"""Add the special trash root folder to the project."""
trashHandle = self._tree.trashRoot
if trashHandle is None:
label = trConst(nwLabels.CLASS_NAME[nwItemClass.TRASH])
return self._tree.create(label, None, nwItemType.ROOT, nwItemClass.TRASH)
return trashHandle
if itemClass != nwItemClass.NO_CLASS:
if not (rHandle := self._tree.findRoot(itemClass)):
rHandle = self.newRoot(itemClass)
if rHandle and (tHandle := SHARED.project.newFile(tag.title(), rHandle)):
self.writeNewFile(tHandle, 1, False, f"@tag: {tag}\n\n")
self._tree.refreshItems([tHandle])
return
##
# Project Methods
@@ -349,7 +368,7 @@ class NWProject:
self._index.loadIndex()
if xmlReader.state == XMLReadState.WAS_LEGACY:
# Often, the index needs to be rebuilt when updating format
self._index.rebuildIndex()
self._index.rebuild()
self.updateWordCounts()
self._session.startSession()
@@ -414,12 +433,11 @@ class NWProject:
def closeProject(self, idleTime: float = 0.0) -> None:
"""Close the project."""
logger.info("Closing project")
self._index.clearIndex() # Triggers clear signal, see #1718
self._index.clear() # Triggers clear signal, see #1718
self._options.saveSettings()
self._tree.writeToCFile()
self._session.appendSession(idleTime)
self._storage.closeSession()
self._lockedBy = None
return
def backupProject(self, doNotify: bool) -> bool:
@@ -489,17 +507,6 @@ class NWProject:
self.setProjectChanged(True)
return
def setTreeOrder(self, order: list[str]) -> None:
"""A list representing the linear/flattened order of project
items in the GUI project tree. The user can rearrange the order
by drag-and-drop. Forwarded to the NWTree class.
"""
if len(self._tree) != len(order):
logger.warning("Sizes of new and old tree order do not match")
self._tree.setOrder(order)
self.setProjectChanged(True)
return
def setProjectChanged(self, status: bool) -> bool:
"""Toggle the project changed flag, and propagate the
information to the GUI statusbar.
@@ -513,47 +520,6 @@ class NWProject:
# Class Methods
##
def iterProjectItems(self) -> Iterable[NWItem]:
"""This function ensures that the item tree loaded is sent to
the GUI tree view in such a way that the tree can be built. That
is, the parent item must be sent before its child. In principle,
a proper XML file will already ensure that, but in the event the
order has been altered, or a file is orphaned, this function is
capable of handling it.
"""
sentItems = set()
iterItems = self._tree.handles()
n = 0
nMax = min(len(iterItems), 10000)
while n < nMax:
tHandle = iterItems[n]
tItem = self._tree[tHandle]
n += 1
if tItem is None:
# Technically a bug
continue
elif tItem.itemParent is None:
# Item is a root, or already been identified as orphaned
sentItems.add(tHandle)
yield tItem
elif tItem.itemParent in sentItems:
# Item's parent has been sent, so all is fine
sentItems.add(tHandle)
yield tItem
elif tItem.itemParent in iterItems:
# Item's parent exists, but hasn't been sent yet, so add
# it again to the end, but make sure this doesn't get
# out hand, so we cap at 10000 items
logger.warning("Item '%s' found before its parent", tHandle)
iterItems.append(tHandle)
nMax = min(len(iterItems), 10000)
else:
# Item is orphaned
logger.error("Item '%s' has no parent in current tree", tHandle)
tItem.setParent(None)
yield tItem
return
def updateWordCounts(self) -> None:
"""Update the total word count values."""
novel, notes = self._tree.sumWords()
@@ -574,6 +540,18 @@ class NWProject:
self._data.itemImport.increment(nwItem.itemImport)
return
def updateStatus(self, kind: T_StatusKind, update: T_UpdateEntry) -> None:
"""Update status or import entries."""
if kind == "s":
self._data.itemStatus.update(update)
SHARED.emitStatusLabelsChanged(self, kind)
self._tree.refreshAllItems()
elif kind == "i":
self._data.itemImport.update(update)
SHARED.emitStatusLabelsChanged(self, kind)
self._tree.refreshAllItems()
return
def localLookup(self, word: str | int) -> str:
"""Look up a word or number in the translation map for the
project and return it. The variable is cast to a string before
+7 -5
View File
@@ -65,6 +65,11 @@ class StatusEntry:
NO_ENTRY = StatusEntry("", QColor(0, 0, 0), nwStatusShape.SQUARE, QIcon(), 0)
if TYPE_CHECKING: # pragma: no cover
# Requires Python 3.10
T_UpdateEntry = list[tuple[str | None, StatusEntry]]
T_StatusKind = Literal["s", "i"]
class NWStatus:
@@ -73,7 +78,7 @@ class NWStatus:
__slots__ = ("_store", "_default", "_prefix", "_height")
def __init__(self, prefix: Literal["s", "i"]) -> None:
def __init__(self, prefix: T_StatusKind) -> None:
self._store: dict[str, StatusEntry] = {}
self._default = None
self._prefix = prefix[:1]
@@ -120,7 +125,7 @@ class NWStatus:
return key
def update(self, update: list[tuple[str | None, StatusEntry]]) -> None:
def update(self, update: T_UpdateEntry) -> None:
"""Update the list of statuses."""
self._store.clear()
for key, entry in update:
@@ -130,9 +135,6 @@ class NWStatus:
if self._default not in self._store:
self._default = next(iter(self._store)) if self._store else None
# Emit the change signal
SHARED.projectSingalProxy({"event": "statusLabels", "kind": self._prefix})
return
def check(self, value: str) -> str:
+271 -290
View File
@@ -3,7 +3,8 @@ novelWriter Project Tree Class
================================
File History:
Created: 2020-05-07 [0.4.5] NWTree
Created: 2020-05-07 [0.4.5] NWTree
Rewritten: 2024-11-16 [2.6b2] NWTree
This file is a part of novelWriter
Copyright 20182024, Veronica Berglyd Olsen
@@ -30,10 +31,13 @@ from collections.abc import Iterable, Iterator
from pathlib import Path
from typing import TYPE_CHECKING, Literal, overload
from novelwriter.common import isHandle
from novelwriter.constants import nwFiles
from PyQt5.QtCore import QModelIndex
from novelwriter import SHARED
from novelwriter.constants import nwFiles, nwLabels, trConst
from novelwriter.core.item import NWItem
from novelwriter.enum import nwItemClass, nwItemLayout, nwItemType
from novelwriter.core.itemmodel import ProjectModel, ProjectNode
from novelwriter.enum import nwChange, nwItemClass, nwItemLayout, nwItemType
from novelwriter.error import logException
if TYPE_CHECKING: # pragma: no cover
@@ -41,7 +45,7 @@ if TYPE_CHECKING: # pragma: no cover
logger = logging.getLogger(__name__)
MAX_DEPTH = 1000 # Cap of tree traversing for loops (recursion limit)
MAX_DEPTH = 999 # Cap of tree traversing for loops (recursion limit)
class NWTree:
@@ -51,30 +55,51 @@ class NWTree:
This class holds all the project items of the project as instances
of NWItem.
For historical reasons, the order of the items is saved in a
separate list from the items themselves, which are stored in a
dictionary. This is somewhat redundant with the newer versions of
Python, but is still practical as it's easier to update the item
order as a list.
Each item has a handle, which is a random hex string of length 13.
The handle is the name of the item everywhere in novelWriter, and is
also used for file names.
"""
__slots__ = ("_project", "_tree", "_order", "_roots", "_trash", "_changed")
__slots__ = ("_project", "_model", "_items", "_nodes", "_trash")
def __init__(self, project: NWProject) -> None:
self._project = project
self._model = ProjectModel(self)
self._items: dict[str, NWItem] = {}
self._nodes: dict[str, ProjectNode] = {}
self._trash = None
logger.debug("Ready: NWTree")
return
self._tree: dict[str, NWItem] = {} # Holds all the items of the project
self._order: list[str] = [] # The order of the tree items in the tree view
self._roots: dict[str, NWItem] = {} # The root items of the tree
def __del__(self) -> None: # pragma: no cover
logger.debug("Delete: NWTree")
return
self._trash = None # The handle of the trash root folder
self._changed = False # True if tree structure has changed
def __len__(self) -> int:
"""The number of items in the project."""
return len(self._items)
def __bool__(self) -> bool:
"""True if there are any items in the project."""
return bool(self._items)
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 and tHandle in self._items:
return self._items[tHandle]
logger.error("No tree item with handle '%s'", str(tHandle))
return None
def __contains__(self, tHandle: str) -> bool:
"""Checks if a handle exists in the tree."""
return tHandle in self._items
def __iter__(self) -> Iterator[NWItem]:
"""Iterate through project items."""
for node in self._model.root.allChildren():
yield node.item
return
##
@@ -82,9 +107,19 @@ class NWTree:
##
@property
def trashRoot(self) -> str | None:
"""Return the handle of the trash folder, or None."""
return self._trash
def trash(self) -> ProjectNode | None:
"""Return trash node, if it exists."""
if self._trash:
return self._trash
return self._getTrashNode()
@property
def model(self) -> ProjectModel:
return self._model
@property
def nodes(self) -> dict[str, ProjectNode]:
return self._nodes
##
# Class Methods
@@ -92,83 +127,91 @@ class NWTree:
def clear(self) -> None:
"""Clear the item tree entirely."""
self._tree = {}
self._order = []
self._roots = {}
self._trash = None
self._changed = False
oldModel = self._model
oldModel.clear()
self._model = ProjectModel(self)
self._items.clear()
self._nodes.clear()
self._trash = None
oldModel.deleteLater()
del oldModel
return
def handles(self) -> list[str]:
"""Returns a copy of the list of all the active handles."""
return self._order.copy()
def add(self, item: NWItem, pos: int = -1) -> bool:
"""Add a project item into the project tree."""
if pHandle := item.itemParent:
if parent := self._nodes.get(pHandle):
node = ProjectNode(item)
index = self._model.indexFromNode(parent)
self._model.insertChild(node, index, pos)
self._nodes[item.itemHandle] = node
self._items[item.itemHandle] = item
self._itemChange(item, nwChange.CREATE)
else:
logger.error("Could not locate parent of '%s'", item.itemHandle)
return False
elif item.isRootType():
node = ProjectNode(item)
self._model.insertChild(node, QModelIndex(), pos)
self._nodes[item.itemHandle] = node
self._items[item.itemHandle] = item
self._itemChange(item, nwChange.CREATE)
else:
logger.error("Invalid project item '%s'", item.itemHandle)
return False
return True
def remove(self, tHandle: str) -> bool:
"""Remove an item from the project tree."""
if (node := self._nodes.get(tHandle)) and tHandle in self._items:
index = self._model.indexFromNode(node)
if index.isValid() and self._model.removeChild(index.parent(), index.row()):
self._itemChange(node.item, nwChange.DELETE)
del self._nodes[tHandle]
del self._items[tHandle]
return True
return False
@overload # pragma: no cover
def create(self, label: str, parent: None, itemType: Literal[nwItemType.ROOT],
itemClass: nwItemClass) -> str:
def create(
self, label: str, parent: None, itemType: Literal[nwItemType.ROOT],
itemClass: nwItemClass, pos: int = -1
) -> str:
pass
@overload # pragma: no cover
def create(self, label: str, parent: str | None, itemType: nwItemType,
itemClass: nwItemClass = nwItemClass.NO_CLASS) -> str | None:
def create(
self, label: str, parent: str | None, itemType: nwItemType,
itemClass: nwItemClass = nwItemClass.NO_CLASS, pos: int = -1
) -> str | None:
pass
def create(self, label, parent, itemType, itemClass=nwItemClass.NO_CLASS):
def create(
self, label: str, parent: str | None, itemType: nwItemType,
itemClass: nwItemClass = nwItemClass.NO_CLASS, pos: int = -1,
) -> str | None:
"""Create a new item in the project tree, and return its handle.
If the item cannot be added to the project because of an invalid
parent, None is returned. For root elements, this cannot occur.
"""
parent = None if itemType == nwItemType.ROOT else parent
if parent is None or parent in self._order:
if parent is None or parent in self._nodes:
tHandle = self._makeHandle()
newItem = NWItem(self._project, tHandle)
newItem.setName(label)
newItem.setParent(parent)
newItem.setType(itemType)
newItem.setClass(itemClass)
self.append(newItem)
self.updateItemData(tHandle)
return tHandle
nwItem = NWItem(self._project, tHandle)
nwItem.setName(label)
nwItem.setParent(parent)
nwItem.setType(itemType)
nwItem.setClass(itemClass)
if self.add(nwItem, pos):
return tHandle
return None
def append(self, nwItem: NWItem) -> bool:
"""Add a new item to the end of the tree."""
tHandle = nwItem.itemHandle
pHandle = nwItem.itemParent
if not isHandle(tHandle):
logger.warning("Invalid item handle '%s' detected, skipping", tHandle)
return False
if tHandle in self._tree:
logger.warning("Duplicate handle '%s' detected, skipping", tHandle)
return False
logger.debug("Adding item '%s' with parent '%s'", str(tHandle), str(pHandle))
if nwItem.isRootType():
logger.debug("Item '%s' is a root item", str(tHandle))
self._roots[tHandle] = nwItem
if nwItem.itemClass == nwItemClass.TRASH:
if self._trash is None:
logger.debug("Item '%s' is the trash folder", str(tHandle))
self._trash = tHandle
else:
logger.error("Only one trash folder allowed")
return False
self._tree[tHandle] = nwItem
self._order.append(tHandle)
self._setTreeChanged(True)
return True
def duplicate(self, sHandle: str) -> NWItem | None:
def duplicate(self, sHandle: str, pHandle: str | None, putAfter: bool) -> NWItem | None:
"""Duplicate an item and set a new handle."""
sItem = self.__getitem__(sHandle)
if isinstance(sItem, NWItem):
nItem = NWItem.duplicate(sItem, self._makeHandle())
if self.append(nItem):
if sNode := self._nodes.get(sHandle):
nItem = NWItem.duplicate(sNode.item, self._makeHandle())
nItem.setParent(pHandle)
if self.add(nItem, (sNode.row() + 1) if putAfter else -1):
logger.info("Duplicated item '%s' -> '%s'", sHandle, nItem.itemHandle)
return nItem
return None
@@ -177,23 +220,62 @@ class NWTree:
"""Pack the content of the tree into a list of dictionaries of
items. In the order defined by the _treeOrder list.
"""
tree = []
for tHandle in self._order:
tItem = self.__getitem__(tHandle)
if tItem:
tree.append(tItem.pack())
return tree
nodes = self._model.root.allChildren()
if len(nodes) != len(self._nodes):
logger.warning(
"Model tree is inconsitent with nodes map, %d != %d",
len(nodes), len(self._nodes)
)
return [node.item.pack() for node in nodes]
def unpack(self, data: list[dict]) -> None:
"""Iterate through all items of a list and add them to the
project tree.
"""
self.clear()
items: dict[str, NWItem] = self._items.copy()
for item in data:
nwItem = NWItem(self._project, "") # Handle is set by unpack()
nwItem = NWItem(self._project, "")
if nwItem.unpack(item):
self.append(nwItem)
nwItem.saveInitialCount()
items[nwItem.itemHandle] = nwItem
later = items
self._model.beginInsertRows(self._model.index(0, 0), 0, 0)
for _ in range(MAX_DEPTH):
later = self._addItems(later)
if len(later) == 0:
break
else:
logger.error("Not all items could be added to project tree")
self._trash = self._getTrashNode()
self._model.endInsertRows()
self._model.layoutChanged.emit()
return
def refreshItems(self, items: list[str]) -> None:
"""Refresh these items on the GUI. If they are an ordered range,
also set the isRange flag to True.
"""
for tHandle in items:
if node := self._nodes.get(tHandle):
node.refresh()
node.updateCount()
indexS = self._model.indexFromNode(node, 0)
indexE = self._model.indexFromNode(node, 3)
self._model.dataChanged.emit(indexS, indexE)
self._itemChange(node.item, nwChange.UPDATE)
return
def refreshAllItems(self) -> None:
"""Refresh all items in the tree."""
for node in reversed(self._model.root.allChildren()):
node.refresh()
node.updateCount(propagate=False)
self._model.root.refresh()
self._model.root.updateCount(propagate=False)
self._model.layoutChanged.emit()
return
def checkConsistency(self, prefix: str) -> tuple[int, int]:
@@ -205,29 +287,21 @@ class NWTree:
mark recovered files.
"""
storage = self._project.storage
files = set(storage.scanContent())
for tHandle in self._order:
if self.updateItemData(tHandle):
logger.debug("Checking item '%s' ... OK", tHandle)
files.discard(tHandle) # Remove it from the record
else:
logger.error("Checking item '%s' ... ERROR", tHandle)
self.__delitem__(tHandle) # The file will be re-added as orphaned
orphans = len(files)
remains = set(storage.scanContent()).difference(set(self._nodes.keys()))
orphans = len(remains)
if orphans == 0:
logger.info("Checked project files: OK")
return 0, 0
logger.warning("Found %d file(s) not tracked in project", orphans)
recovered = 0
for cHandle in files:
for cHandle in remains:
aDoc = storage.getDocument(cHandle)
aDoc.readDocument(isOrphan=True)
oName, oParent, oClass, oLayout = aDoc.getMeta()
oName = oName or cHandle
oParent = oParent if oParent in self._order else None
oParent = oParent if oParent in self._nodes else None
oClass = oClass or nwItemClass.NOVEL
oLayout = oLayout or nwItemLayout.NOTE
@@ -248,8 +322,7 @@ class NWTree:
newItem.setType(nwItemType.FILE)
newItem.setClass(oClass)
newItem.setLayout(oLayout)
if self.append(newItem):
self.updateItemData(cHandle)
if self.add(newItem):
recovered += 1
return orphans, recovered
@@ -263,38 +336,33 @@ class NWTree:
if not (isinstance(contentPath, Path) and isinstance(runtimePath, Path)):
return False
tocList = []
tocLen = 0
for tHandle in self._order:
tItem = self.__getitem__(tHandle)
if tItem is None:
continue
tFile = tHandle+".nwd"
if (contentPath / tFile).is_file():
entries = []
maxLen = 0
for node in self._model.root.allChildren():
item = node.item
file = f"{item.itemHandle}.nwd"
if (contentPath / file).is_file():
tocLine = "{0:<25s} {1:<9s} {2:<8s} {3:s}".format(
str(Path("content") / tFile),
tItem.itemClass.name,
tItem.itemLayout.name,
tItem.itemName,
f"content/{file}",
item.itemClass.name,
item.itemLayout.name,
item.itemName,
)
tocList.append(tocLine)
tocLen = max(tocLen, len(tocLine))
entries.append(tocLine)
maxLen = max(maxLen, len(tocLine))
try:
# Dump the text
tocText = runtimePath / nwFiles.TOC_TXT
with open(tocText, mode="w", encoding="utf-8") as outFile:
outFile.write("\n")
outFile.write("Table of Contents\n")
outFile.write("=================\n")
outFile.write("\n")
outFile.write("{0:<25s} {1:<9s} {2:<8s} {3:s}\n".format(
with open(runtimePath / nwFiles.TOC_TXT, mode="w", encoding="utf-8") as toc:
toc.write("\n")
toc.write("Table of Contents\n")
toc.write("=================\n")
toc.write("\n")
toc.write("{0:<25s} {1:<9s} {2:<8s} {3:s}\n".format(
"File Name", "Class", "Layout", "Document Label"
))
outFile.write("-"*max(tocLen, 62) + "\n")
outFile.write("\n".join(tocList))
outFile.write("\n")
toc.write("-"*max(maxLen, 62) + "\n")
toc.write("\n".join(entries))
toc.write("\n")
except Exception:
logger.error("Could not write ToC file")
@@ -307,74 +375,46 @@ class NWTree:
"""Loop over all entries and add up the word counts."""
noteWords = 0
novelWords = 0
for tHandle in self._order:
tItem = self.__getitem__(tHandle)
if tItem is None:
continue
if tItem.itemLayout == nwItemLayout.NO_LAYOUT:
pass
elif tItem.itemLayout == nwItemLayout.NOTE:
noteWords += tItem.wordCount
else:
novelWords += tItem.wordCount
for item in self._items.values():
if item.itemLayout == nwItemLayout.NOTE:
noteWords += item.wordCount
elif item.itemLayout == nwItemLayout.DOCUMENT:
novelWords += item.wordCount
return novelWords, noteWords
##
# Tree Item Methods
##
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.
"""
tItem = self.__getitem__(tHandle)
if tItem is None:
return False
iItem = tItem
for _ in range(MAX_DEPTH):
if iItem.itemParent is None:
tItem.setRoot(iItem.itemHandle)
tItem.setClassDefaults(iItem.itemClass)
return True
else:
iItem = self.__getitem__(iItem.itemParent)
if iItem is None:
return False
else:
raise RecursionError("Critical internal error")
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
if tItem := self.__getitem__(tHandle):
return tItem.itemType == itemType
return False
def getItemPath(self, tHandle: str, asName: bool = False) -> list[str]:
def itemPath(self, tHandle: str, asName: bool = False) -> list[str]:
"""Iterate upwards in the tree until we find the item with
parent None, the root item, and return the list of handles, or
alternatively item names. We do this with a for loop with a
maximum depth to make infinite loops impossible.
"""
tTree = []
tItem = self.__getitem__(tHandle)
if tItem is not None:
tTree.append(tItem.itemName if asName else tHandle)
path = []
if node := self._nodes.get(tHandle):
for _ in range(MAX_DEPTH):
if tItem.itemParent is None:
return tTree
if parent := node.parent():
path.append(node.item.itemName if asName else tHandle)
node = parent
else:
tHandle = tItem.itemParent
tItem = self.__getitem__(tHandle)
if tItem is None:
return tTree
else:
tTree.append(tItem.itemName if asName else tHandle)
return path
else:
raise RecursionError("Critical internal error")
logger.error("Max project tree depth reached")
return path
return tTree
def subTree(self, tHandle: str) -> list[str]:
"""Get the subtree from a given handle."""
if node := self._nodes.get(tHandle):
return [child.item.itemHandle for child in node.allChildren()]
return []
##
# Tree Root Methods
@@ -383,139 +423,80 @@ class NWTree:
def rootClasses(self) -> set[nwItemClass]:
"""Return a set of all root classes in use by the project."""
rootClasses = set()
for nwItem in self._roots.values():
rootClasses.add(nwItem.itemClass)
for node in self._model.root.children:
rootClasses.add(node.item.itemClass)
return rootClasses
def iterRoots(self, itemClass: nwItemClass | None) -> Iterable[tuple[str, NWItem]]:
"""Iterate over all root items of a given class in order."""
for tHandle in self._order:
nwItem = self.__getitem__(tHandle)
if isinstance(nwItem, NWItem) and nwItem.isRootType():
if itemClass is None or nwItem.itemClass == itemClass:
yield tHandle, nwItem
for node in self._model.root.children:
if node.item.isRootType():
if itemClass is None or node.item.itemClass == itemClass:
yield node.item.itemHandle, node.item
return
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
if tItem.itemClass == nwItemClass.TRASH:
return True
if self._trash is not None:
if tHandle == self._trash:
return True
elif tItem.itemParent == self._trash:
return True
elif tItem.itemRoot == self._trash:
return True
return False
def findRoot(self, itemClass: nwItemClass | None) -> str | None:
"""Find the first root item for a given class."""
for aRoot in self._roots:
tItem = self.__getitem__(aRoot)
if tItem is None:
continue
if itemClass == tItem.itemClass:
return tItem.itemHandle
for node in self._model.root.children:
if node.item.itemClass == itemClass:
return node.item.itemHandle
return None
##
# Setters
##
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._tree]
if not (len(tmpOrder) == len(newOrder) == len(self._order)):
# Something is wrong, so let's debug it
for tHandle in newOrder:
if tHandle not in self._tree:
logger.error("Handle '%s' in new tree order is not in old order", tHandle)
for tHandle in self._order:
if tHandle not in tmpOrder:
logger.warning("Handle '%s' in old tree order is not in new order", tHandle)
# Save the temp list
self._order = tmpOrder
self._setTreeChanged(True)
logger.debug("Project tree order updated")
return
##
# Special Methods
##
def __len__(self) -> int:
"""The number of items in the project."""
return len(self._order)
def __bool__(self) -> bool:
"""True if there are any items in the project."""
return bool(self._order)
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 and tHandle in self._tree:
return self._tree[tHandle]
logger.error("No tree item with handle '%s'", str(tHandle))
return None
def __delitem__(self, tHandle: str) -> None:
"""Remove an item from the internal lists and dictionaries."""
if tHandle in self._order and tHandle in self._tree:
self._order.remove(tHandle)
del self._tree[tHandle]
else:
logger.warning("Failed to delete item '%s': item not found", tHandle)
return
if tHandle in self._roots:
del self._roots[tHandle]
if tHandle == self._trash:
self._trash = None
self._setTreeChanged(True)
return
def __contains__(self, tHandle: str) -> bool:
"""Checks if a handle exists in the tree."""
return tHandle in self._order
def __iter__(self) -> Iterator[NWItem]:
"""Iterate through project items."""
for tHandle in self._order:
tItem = self._tree.get(tHandle)
if isinstance(tItem, NWItem):
yield tItem
return
##
# Internal Functions
##
def _setTreeChanged(self, state: bool) -> None:
"""Set the changed flag to state, and if being set to True,
propagate that state change to the parent NWProject class.
"""
self._changed = state
if state:
self._project.setProjectChanged(True)
def _itemChange(self, item: NWItem, change: nwChange) -> None:
"""Signal item change and notify project."""
tHandle = item.itemHandle
logger.debug("Item change: %s -> %s", tHandle, change.name)
self._project.setProjectChanged(True)
SHARED.emitProjectItemChanged(self._project, tHandle, change)
if item.isRootType():
SHARED.emitRootFolderChanged(self._project, tHandle, change)
return
def _getTrashNode(self) -> ProjectNode | None:
"""Get the trash node. If it doesn't exist, create it."""
for node in self._model.root.children:
if node.item.itemClass == nwItemClass.TRASH:
return node
label = trConst(nwLabels.CLASS_NAME[nwItemClass.TRASH])
if handle := self.create(label, None, nwItemType.ROOT, nwItemClass.TRASH):
return self._nodes.get(handle)
return None
def _addItems(self, items: dict[str, NWItem]) -> dict[str, NWItem]:
"""Add a dictionary of items to the project tree. Returns a new
dictionary of items that could not be added yet, but can be.
"""
remains: dict[str, NWItem] = {}
for handle, item in items.items():
if pHandle := item.itemParent:
if parent := self._nodes.get(pHandle):
node = ProjectNode(item)
parent.addChild(node)
parent.updateCount()
self._items[handle] = item
self._nodes[handle] = node
elif pHandle in items:
remains[handle] = item
logger.warning("Item '%s' found before its parent", handle)
elif item.isRootType():
node = ProjectNode(item)
self._model.root.addChild(node)
self._model.root.updateCount()
self._items[handle] = item
self._nodes[handle] = node
return remains
def _makeHandle(self) -> str:
"""Generate a unique item handle. In the event that the key
already exists, generate a new one.
"""
logger.debug("Generating new handle")
handle = f"{random.getrandbits(52):013x}"
if handle in self._tree:
if handle in self._items:
logger.warning("Duplicate handle encountered! Retrying ...")
handle = self._makeHandle()
+7 -17
View File
@@ -162,23 +162,13 @@ class GuiDocMerge(NDialog):
self._data = {}
self._data["sHandle"] = sHandle
self._data["origItems"] = itemList
self.listBox.clear()
for tHandle in itemList:
nwItem = SHARED.project.tree[tHandle]
if nwItem is None or not nwItem.isFileType():
continue
itemIcon = SHARED.theme.getItemIcon(
nwItem.itemType, nwItem.itemClass, nwItem.itemLayout, nwItem.mainHeading
)
newItem = QListWidgetItem()
newItem.setIcon(itemIcon)
newItem.setText(nwItem.itemName)
newItem.setData(self.D_HANDLE, tHandle)
newItem.setCheckState(Qt.CheckState.Checked)
self.listBox.addItem(newItem)
if (nwItem := SHARED.project.tree[tHandle]) and nwItem.isFileType():
item = QListWidgetItem()
item.setIcon(nwItem.getMainIcon())
item.setText(nwItem.itemName)
item.setData(self.D_HANDLE, tHandle)
item.setCheckState(Qt.CheckState.Checked)
self.listBox.addItem(item)
return
+2 -2
View File
@@ -185,11 +185,11 @@ class GuiProjectSettings(NDialog):
if self.statusPage.changed:
logger.debug("Updating status labels")
project.data.itemStatus.update(self.statusPage.getNewList())
project.updateStatus("s", self.statusPage.getNewList())
if self.importPage.changed:
logger.debug("Updating importance labels")
project.data.itemImport.update(self.importPage.getNewList())
project.updateStatus("i", self.importPage.getNewList())
if self.replacePage.changed:
logger.debug("Updating auto-replace settings")
+7
View File
@@ -75,6 +75,13 @@ class nwTrinary(Enum):
POSITIVE = 1
class nwChange(Enum):
CREATE = 0
UPDATE = 1
DELETE = 2
class nwDocMode(Enum):
VIEW = 0
+1 -3
View File
@@ -183,7 +183,7 @@ class NScrollableForm(QScrollArea):
def addRow(
self,
label: str | None,
widget: QWidget | list[QWidget | QPixmap | str | int],
widget: QWidget | list[QWidget | QPixmap | int],
helpText: str = "",
unit: str | None = None,
button: QWidget | None = None,
@@ -204,8 +204,6 @@ class NScrollableForm(QScrollArea):
icon = QLabel(self)
icon.setPixmap(item)
wBox.addWidget(icon)
elif isinstance(item, str):
wBox.addWidget(QLabel(item, self))
elif isinstance(item, int):
wBox.addSpacing(CONFIG.pxInt(item))
qWidget = QWidget(self)
+39 -11
View File
@@ -43,8 +43,9 @@ from PyQt5.QtCore import (
pyqtSlot
)
from PyQt5.QtGui import (
QColor, QCursor, QKeyEvent, QKeySequence, QMouseEvent, QPalette, QPixmap,
QResizeEvent, QTextBlock, QTextCursor, QTextDocument, QTextOption
QColor, QCursor, QDragEnterEvent, QDragMoveEvent, QDropEvent, QKeyEvent,
QKeySequence, QMouseEvent, QPalette, QPixmap, QResizeEvent, QTextBlock,
QTextCursor, QTextDocument, QTextOption
)
from PyQt5.QtWidgets import (
QAction, QApplication, QFrame, QGridLayout, QHBoxLayout, QLabel, QLineEdit,
@@ -52,12 +53,12 @@ from PyQt5.QtWidgets import (
)
from novelwriter import CONFIG, SHARED
from novelwriter.common import minmax, qtLambda, transferCase
from novelwriter.common import decodeMimeHandles, minmax, qtLambda, transferCase
from novelwriter.constants import nwConst, nwKeyWords, nwShortcode, nwUnicode
from novelwriter.core.document import NWDocument
from novelwriter.enum import (
nwComment, nwDocAction, nwDocInsert, nwDocMode, nwItemClass, nwItemType,
nwTrinary
nwChange, nwComment, nwDocAction, nwDocInsert, nwDocMode, nwItemClass,
nwItemType, nwTrinary
)
from novelwriter.extensions.configlayout import NColourLabel
from novelwriter.extensions.eventfilters import WheelEventFilter
@@ -109,13 +110,13 @@ class GuiDocEditor(QPlainTextEdit):
# Custom Signals
closeEditorRequest = pyqtSignal()
docCountsChanged = pyqtSignal(str, int, int, int)
docTextChanged = pyqtSignal(str, float)
editedStatusChanged = pyqtSignal(bool)
itemHandleChanged = pyqtSignal(str)
loadDocumentTagRequest = pyqtSignal(str, Enum)
novelItemMetaChanged = pyqtSignal(str)
novelStructureChanged = pyqtSignal()
openDocumentRequest = pyqtSignal(str, Enum, str, bool)
requestNewNoteCreation = pyqtSignal(str, nwItemClass)
requestNextDocument = pyqtSignal(str, bool)
requestProjectItemRenamed = pyqtSignal(str, str)
@@ -191,6 +192,7 @@ class GuiDocEditor(QPlainTextEdit):
self.setMinimumWidth(CONFIG.pxInt(300))
self.setAutoFillBackground(True)
self.setFrameStyle(QFrame.Shape.NoFrame)
self.setAcceptDrops(True)
# Custom Shortcuts
self.keyContext = QShortcut(self)
@@ -997,6 +999,32 @@ class GuiDocEditor(QPlainTextEdit):
return
def dragEnterEvent(self, event: QDragEnterEvent) -> None:
"""Overload drag enter event to handle dragged items."""
if event.mimeData().hasFormat(nwConst.MIME_HANDLE):
event.acceptProposedAction()
else:
super().dragEnterEvent(event)
return
def dragMoveEvent(self, event: QDragMoveEvent) -> None:
"""Overload drag move event to handle dragged items."""
if event.mimeData().hasFormat(nwConst.MIME_HANDLE):
event.acceptProposedAction()
else:
super().dragMoveEvent(event)
return
def dropEvent(self, event: QDropEvent) -> None:
"""Overload drop event to handle dragged items."""
if event.mimeData().hasFormat(nwConst.MIME_HANDLE):
if handles := decodeMimeHandles(event.mimeData()):
if SHARED.project.tree.checkType(handles[0], nwItemType.FILE):
self.openDocumentRequest.emit(handles[0], nwDocMode.EDIT, "", True)
else:
super().dropEvent(event)
return
def focusNextPrevChild(self, next: bool) -> bool:
"""Capture the focus request from the tab key on the text
editor. If the editor has focus, we do not change focus and
@@ -1036,12 +1064,12 @@ class GuiDocEditor(QPlainTextEdit):
# Public Slots
##
@pyqtSlot(str)
def updateDocInfo(self, tHandle: str) -> None:
@pyqtSlot(str, Enum)
def onProjectItemChanged(self, tHandle: str, change: nwChange) -> None:
"""Called when an item label is changed to check if the document
title bar needs updating,
"""
if tHandle and tHandle == self._docHandle:
if tHandle == self._docHandle and change == nwChange.UPDATE:
self.docHeader.setHandle(tHandle)
self.docFooter.updateInfo()
self.updateDocMargins()
@@ -1252,7 +1280,7 @@ class GuiDocEditor(QPlainTextEdit):
self._nwItem.setCharCount(cCount)
self._nwItem.setWordCount(wCount)
self._nwItem.setParaCount(pCount)
self.docCountsChanged.emit(self._docHandle, cCount, wCount, pCount)
self._nwItem.notifyToRefresh()
self.docFooter.updateWordCount(wCount, False)
return
@@ -2985,7 +3013,7 @@ class GuiDocEditHeader(QWidget):
if CONFIG.showFullPath:
self.itemTitle.setText(f" {nwUnicode.U_RSAQUO} ".join(reversed(
[name for name in SHARED.project.tree.getItemPath(tHandle, asName=True)]
[name for name in SHARED.project.tree.itemPath(tHandle, asName=True)]
)))
else:
self.itemTitle.setText(i.itemName if (i := SHARED.project.tree[tHandle]) else "")
+37 -8
View File
@@ -31,16 +31,19 @@ import logging
from enum import Enum
from PyQt5.QtCore import QPoint, Qt, QUrl, pyqtSignal, pyqtSlot
from PyQt5.QtGui import QCursor, QDesktopServices, QMouseEvent, QPalette, QResizeEvent, QTextCursor
from PyQt5.QtGui import (
QCursor, QDesktopServices, QDragEnterEvent, QDragMoveEvent, QDropEvent,
QMouseEvent, QPalette, QResizeEvent, QTextCursor
)
from PyQt5.QtWidgets import (
QAction, QApplication, QFrame, QHBoxLayout, QMenu, QTextBrowser,
QToolButton, QWidget
)
from novelwriter import CONFIG, SHARED
from novelwriter.common import qtLambda
from novelwriter.constants import nwStyles, nwUnicode
from novelwriter.enum import nwDocAction, nwDocMode, nwItemType
from novelwriter.common import decodeMimeHandles, qtLambda
from novelwriter.constants import nwConst, nwStyles, nwUnicode
from novelwriter.enum import nwChange, nwDocAction, nwDocMode, nwItemType
from novelwriter.error import logException
from novelwriter.extensions.configlayout import NColourLabel
from novelwriter.extensions.eventfilters import WheelEventFilter
@@ -343,10 +346,10 @@ class GuiDocViewer(QTextBrowser):
# Public Slots
##
@pyqtSlot(str)
def updateDocInfo(self, tHandle: str) -> None:
@pyqtSlot(str, Enum)
def onProjectItemChanged(self, tHandle: str, change: nwChange) -> None:
"""Update the header title bar if needed."""
if tHandle and tHandle == self._docHandle:
if tHandle == self._docHandle and change == nwChange.UPDATE:
self.docHeader.setHandle(tHandle)
self.updateDocMargins()
return
@@ -445,6 +448,32 @@ class GuiDocViewer(QTextBrowser):
super().mouseReleaseEvent(event)
return
def dragEnterEvent(self, event: QDragEnterEvent) -> None:
"""Overload drag enter event to handle dragged items."""
if event.mimeData().hasFormat(nwConst.MIME_HANDLE):
event.acceptProposedAction()
else:
super().dragEnterEvent(event)
return
def dragMoveEvent(self, event: QDragMoveEvent) -> None:
"""Overload drag move event to handle dragged items."""
if event.mimeData().hasFormat(nwConst.MIME_HANDLE):
event.acceptProposedAction()
else:
super().dragMoveEvent(event)
return
def dropEvent(self, event: QDropEvent) -> None:
"""Overload drop event to handle dragged items."""
if event.mimeData().hasFormat(nwConst.MIME_HANDLE):
if handles := decodeMimeHandles(event.mimeData()):
if SHARED.project.tree.checkType(handles[0], nwItemType.FILE):
self.openDocumentRequest.emit(handles[0], nwDocMode.VIEW, "", True)
else:
super().dropEvent(event)
return
##
# Internal Functions
##
@@ -777,7 +806,7 @@ class GuiDocViewHeader(QWidget):
if CONFIG.showFullPath:
self.itemTitle.setText(f" {nwUnicode.U_RSAQUO} ".join(reversed(
[name for name in SHARED.project.tree.getItemPath(tHandle, asName=True)]
[name for name in SHARED.project.tree.itemPath(tHandle, asName=True)]
)))
else:
self.itemTitle.setText(i.itemName if (i := SHARED.project.tree[tHandle]) else "")
+14 -22
View File
@@ -29,18 +29,18 @@ from enum import Enum
from PyQt5.QtCore import QModelIndex, Qt, pyqtSignal, pyqtSlot
from PyQt5.QtWidgets import (
QAbstractItemView, QFrame, QHeaderView, QMenu, QTabWidget, QToolButton,
QTreeWidget, QTreeWidgetItem, QVBoxLayout, QWidget
QAbstractItemView, QFrame, QMenu, QTabWidget, QToolButton, QTreeWidget,
QTreeWidgetItem, QVBoxLayout, QWidget
)
from novelwriter import CONFIG, SHARED
from novelwriter.common import checkInt
from novelwriter.constants import nwLabels, nwLists, nwStyles, trConst
from novelwriter.core.index import IndexHeading, IndexItem
from novelwriter.enum import nwDocMode, nwItemClass
from novelwriter.enum import nwChange, nwDocMode, nwItemClass
from novelwriter.extensions.modified import NIconToolButton
from novelwriter.gui.theme import STYLES_FLAT_TABS, STYLES_MIN_TOOLBUTTON
from novelwriter.types import QtDecoration, QtUserRole
from novelwriter.types import QtDecoration, QtHeaderFixed, QtHeaderToContents, QtUserRole
logger = logging.getLogger(__name__)
@@ -151,8 +151,8 @@ class GuiDocViewerPanel(QWidget):
self.updateHandle(self._lastHandle)
return
@pyqtSlot(str)
def projectItemChanged(self, tHandle: str) -> None:
@pyqtSlot(str, Enum)
def onProjectItemChanged(self, tHandle: str, change: nwChange) -> None:
"""Update meta data for project item."""
self.tabBackRefs.refreshDocument(tHandle)
activeOnly = self.aInactive.isChecked()
@@ -259,10 +259,10 @@ class _ViewPanelBackRefs(QTreeWidget):
treeHeader = self.header()
treeHeader.setStretchLastSection(True)
treeHeader.setMinimumSectionSize(iPx + cMg) # See Issue #1627
treeHeader.setSectionResizeMode(self.C_DOC, QHeaderView.ResizeMode.ResizeToContents)
treeHeader.setSectionResizeMode(self.C_EDIT, QHeaderView.ResizeMode.Fixed)
treeHeader.setSectionResizeMode(self.C_VIEW, QHeaderView.ResizeMode.Fixed)
treeHeader.setSectionResizeMode(self.C_TITLE, QHeaderView.ResizeMode.ResizeToContents)
treeHeader.setSectionResizeMode(self.C_DOC, QtHeaderToContents)
treeHeader.setSectionResizeMode(self.C_EDIT, QtHeaderFixed)
treeHeader.setSectionResizeMode(self.C_VIEW, QtHeaderFixed)
treeHeader.setSectionResizeMode(self.C_TITLE, QtHeaderToContents)
treeHeader.resizeSection(self.C_EDIT, iPx + cMg)
treeHeader.resizeSection(self.C_VIEW, iPx + cMg)
treeHeader.setSectionsMovable(False)
@@ -339,17 +339,13 @@ class _ViewPanelBackRefs(QTreeWidget):
def _setTreeItemValues(self, tHandle: str, sTitle: str, hItem: IndexHeading) -> None:
"""Add or update a tree item."""
if nwItem := SHARED.project.tree[tHandle]:
docIcon = SHARED.theme.getItemIcon(
nwItem.itemType, nwItem.itemClass,
nwItem.itemLayout, nwItem.mainHeading
)
iLevel = nwStyles.H_LEVEL.get(hItem.level, 0) if nwItem.isDocumentLayout() else 5
hDec = SHARED.theme.getHeaderDecorationNarrow(iLevel)
tKey = f"{tHandle}:{sTitle}"
trItem = self._treeMap[tKey] if tKey in self._treeMap else QTreeWidgetItem()
trItem.setIcon(self.C_DOC, docIcon)
trItem.setIcon(self.C_DOC, nwItem.getMainIcon())
trItem.setText(self.C_DOC, nwItem.itemName)
trItem.setToolTip(self.C_DOC, nwItem.itemName)
trItem.setIcon(self.C_EDIT, self._editIcon)
@@ -407,8 +403,8 @@ class _ViewPanelKeyWords(QTreeWidget):
treeHeader = self.header()
treeHeader.setStretchLastSection(True)
treeHeader.setMinimumSectionSize(iPx + cMg) # See Issue #1627
treeHeader.setSectionResizeMode(self.C_EDIT, QHeaderView.ResizeMode.Fixed)
treeHeader.setSectionResizeMode(self.C_VIEW, QHeaderView.ResizeMode.Fixed)
treeHeader.setSectionResizeMode(self.C_EDIT, QtHeaderFixed)
treeHeader.setSectionResizeMode(self.C_VIEW, QtHeaderFixed)
treeHeader.resizeSection(self.C_EDIT, iPx + cMg)
treeHeader.resizeSection(self.C_VIEW, iPx + cMg)
treeHeader.setSectionsMovable(False)
@@ -448,10 +444,6 @@ class _ViewPanelKeyWords(QTreeWidget):
def addUpdateEntry(self, tag: str, name: str, iItem: IndexItem, hItem: IndexHeading) -> None:
"""Add a new entry, or update an existing one."""
nwItem = iItem.item
docIcon = SHARED.theme.getItemIcon(
nwItem.itemType, nwItem.itemClass,
nwItem.itemLayout, nwItem.mainHeading
)
impLabel, impIcon = nwItem.getImportStatus()
iLevel = nwStyles.H_LEVEL.get(hItem.level, 0) if nwItem.isDocumentLayout() else 5
hDec = SHARED.theme.getHeaderDecorationNarrow(iLevel)
@@ -468,7 +460,7 @@ class _ViewPanelKeyWords(QTreeWidget):
trItem.setIcon(self.C_IMPORT, impIcon)
trItem.setText(self.C_IMPORT, impLabel)
trItem.setToolTip(self.C_IMPORT, impLabel)
trItem.setIcon(self.C_DOC, docIcon)
trItem.setIcon(self.C_DOC, nwItem.getMainIcon())
trItem.setText(self.C_DOC, nwItem.itemName)
trItem.setToolTip(self.C_DOC, nwItem.itemName)
trItem.setData(self.C_TITLE, QtDecoration, hDec)
+17 -24
View File
@@ -25,12 +25,15 @@ from __future__ import annotations
import logging
from enum import Enum
from PyQt5.QtCore import pyqtSlot
from PyQt5.QtWidgets import QGridLayout, QLabel, QWidget
from novelwriter import CONFIG, SHARED
from novelwriter.common import elide
from novelwriter.constants import nwLabels, nwStats, trConst
from novelwriter.enum import nwChange
from novelwriter.types import (
QtAlignLeft, QtAlignLeftBase, QtAlignRight, QtAlignRightBase,
QtAlignRightMiddle
@@ -220,19 +223,9 @@ class GuiItemDetails(QWidget):
self.updateViewBox(self._handle)
return
##
# Public Slots
##
@pyqtSlot(str)
def updateViewBox(self, tHandle: str) -> None:
def updateViewBox(self, tHandle: str | None) -> None:
"""Populate the details box from a given handle."""
if tHandle is None:
self.clearDetails()
return
nwItem = SHARED.project.tree[tHandle]
if nwItem is None:
if not (tHandle and (nwItem := SHARED.project.tree[tHandle])):
self.clearDetails()
return
@@ -269,10 +262,7 @@ class GuiItemDetails(QWidget):
# Layout
# ======
usageIcon = SHARED.theme.getItemIcon(
nwItem.itemType, nwItem.itemClass, nwItem.itemLayout, nwItem.mainHeading
)
self.usageIcon.setPixmap(usageIcon.pixmap(iPx, iPx))
self.usageIcon.setPixmap(nwItem.getMainIcon().pixmap(iPx, iPx))
self.usageData.setText(nwItem.describeMe())
# Counts
@@ -289,13 +279,16 @@ class GuiItemDetails(QWidget):
return
@pyqtSlot(str, int, int, int)
def updateCounts(self, tHandle: str, cC: int, wC: int, pC: int) -> None:
"""Update the counts if the handle is the same as the one we're
already showing. Otherwise, do nothing.
"""
##
# Public Slots
##
@pyqtSlot(str, Enum)
def onProjectItemChanged(self, tHandle: str, change: nwChange) -> None:
"""Process project item change."""
if tHandle == self._handle:
self.cCountData.setText(f"{cC:n}")
self.wCountData.setText(f"{wC:n}")
self.pCountData.setText(f"{pC:n}")
if change == nwChange.UPDATE:
self.updateViewBox(tHandle)
elif change == nwChange.DELETE:
self.updateViewBox(None)
return
+13 -8
View File
@@ -28,7 +28,7 @@ import logging
from pathlib import Path
from typing import TYPE_CHECKING
from PyQt5.QtCore import pyqtSignal, pyqtSlot
from PyQt5.QtCore import Qt, pyqtSignal, pyqtSlot
from PyQt5.QtWidgets import QAction, QMenuBar
from novelwriter import CONFIG, SHARED
@@ -162,19 +162,20 @@ class GuiMainMenu(QMenuBar):
self.projMenu.addSeparator()
# Project > Edit
self.aEditItem = self.projMenu.addAction(self.tr("Rename Item"))
self.aEditItem.setShortcut("F2")
self.aEditItem.triggered.connect(qtLambda(self.mainGui.projView.renameTreeItem, None))
self.mainGui.addAction(self.aEditItem)
self.aRenameItem = self.projMenu.addAction(self.tr("Rename Item"))
self.aRenameItem.setShortcut("F2")
# Project > Delete
self.aDeleteItem = self.projMenu.addAction(self.tr("Delete Item"))
self.aDeleteItem.setShortcut("Ctrl+Shift+Del") # Cannot be Ctrl+Del, see #629
self.aDeleteItem.triggered.connect(qtLambda(self.mainGui.projView.requestDeleteItem, None))
self.aDeleteItem.setShortcut("Del")
self.aDeleteItem.setShortcutContext(Qt.ShortcutContext.WidgetShortcut)
# Project > Empty Trash
self.aEmptyTrash = self.projMenu.addAction(self.tr("Empty Trash"))
self.aEmptyTrash.triggered.connect(qtLambda(self.mainGui.projView.emptyTrash))
self.mainGui.projView.connectMenuActions(
self.aRenameItem, self.aDeleteItem, self.aEmptyTrash
)
# Project > Separator
self.projMenu.addSeparator()
@@ -337,12 +338,16 @@ class GuiMainMenu(QMenuBar):
# View > Go Backward
self.aViewPrev = self.viewMenu.addAction(self.tr("Navigate Backward"))
self.aViewPrev.setShortcut("Alt+Left")
self.aViewPrev.setShortcutContext(Qt.ShortcutContext.WidgetShortcut)
self.aViewPrev.triggered.connect(self.mainGui.docViewer.navBackward)
self.mainGui.docViewer.addAction(self.aViewPrev)
# View > Go Forward
self.aViewNext = self.viewMenu.addAction(self.tr("Navigate Forward"))
self.aViewNext.setShortcut("Alt+Right")
self.aViewNext.setShortcutContext(Qt.ShortcutContext.WidgetShortcut)
self.aViewNext.triggered.connect(self.mainGui.docViewer.navForward)
self.mainGui.docViewer.addAction(self.aViewNext)
# View > Separator
self.viewMenu.addSeparator()
+12 -12
View File
@@ -33,22 +33,22 @@ from time import time
from PyQt5.QtCore import QModelIndex, QPoint, Qt, pyqtSignal, pyqtSlot
from PyQt5.QtGui import QFocusEvent, QFont, QMouseEvent, QPalette, QResizeEvent
from PyQt5.QtWidgets import (
QAbstractItemView, QActionGroup, QFrame, QHBoxLayout, QHeaderView,
QInputDialog, QMenu, QToolTip, QTreeWidget, QTreeWidgetItem, QVBoxLayout,
QWidget
QAbstractItemView, QActionGroup, QFrame, QHBoxLayout, QInputDialog, QMenu,
QToolTip, QTreeWidget, QTreeWidgetItem, QVBoxLayout, QWidget
)
from novelwriter import CONFIG, SHARED
from novelwriter.common import minmax, qtLambda
from novelwriter.constants import nwKeyWords, nwLabels, nwStyles, trConst
from novelwriter.core.index import IndexHeading
from novelwriter.enum import nwDocMode, nwItemClass, nwOutline
from novelwriter.enum import nwChange, nwDocMode, nwItemClass, nwOutline
from novelwriter.extensions.modified import NIconToolButton
from novelwriter.extensions.novelselector import NovelSelector
from novelwriter.gui.theme import STYLES_MIN_TOOLBUTTON
from novelwriter.types import (
QtAlignRight, QtDecoration, QtMouseLeft, QtMouseMiddle, QtScrollAlwaysOff,
QtScrollAsNeeded, QtSizeExpanding, QtUserRole
QtAlignRight, QtDecoration, QtHeaderStretch, QtHeaderToContents,
QtMouseLeft, QtMouseMiddle, QtScrollAlwaysOff, QtScrollAsNeeded,
QtSizeExpanding, QtUserRole
)
logger = logging.getLogger(__name__)
@@ -174,8 +174,8 @@ class GuiNovelView(QWidget):
self.novelTree.refreshTree(rootHandle=SHARED.project.data.getLastHandle("novelTree"))
return
@pyqtSlot(str)
def updateRootItem(self, tHandle: str) -> None:
@pyqtSlot(str, Enum)
def updateRootItem(self, tHandle: str, change: nwChange) -> None:
"""If any root item changes, rebuild the novel root menu."""
self.novelBar.buildNovelRootMenu()
return
@@ -406,10 +406,10 @@ class GuiNovelTree(QTreeWidget):
treeHeader = self.header()
treeHeader.setStretchLastSection(False)
treeHeader.setMinimumSectionSize(iPx + cMg)
treeHeader.setSectionResizeMode(self.C_TITLE, QHeaderView.ResizeMode.Stretch)
treeHeader.setSectionResizeMode(self.C_WORDS, QHeaderView.ResizeMode.ResizeToContents)
treeHeader.setSectionResizeMode(self.C_EXTRA, QHeaderView.ResizeMode.ResizeToContents)
treeHeader.setSectionResizeMode(self.C_MORE, QHeaderView.ResizeMode.ResizeToContents)
treeHeader.setSectionResizeMode(self.C_TITLE, QtHeaderStretch)
treeHeader.setSectionResizeMode(self.C_WORDS, QtHeaderToContents)
treeHeader.setSectionResizeMode(self.C_EXTRA, QtHeaderToContents)
treeHeader.setSectionResizeMode(self.C_MORE, QtHeaderToContents)
# Pre-Generate Tree Formatting
fH1 = self.font()
+10 -11
View File
@@ -43,7 +43,7 @@ from PyQt5.QtWidgets import (
from novelwriter import CONFIG, SHARED
from novelwriter.common import checkInt, formatFileFilter, makeFileNameSafe
from novelwriter.constants import nwKeyWords, nwLabels, nwStats, nwStyles, trConst
from novelwriter.enum import nwDocMode, nwItemClass, nwItemLayout, nwItemType, nwOutline
from novelwriter.enum import nwChange, nwDocMode, nwItemClass, nwItemLayout, nwItemType, nwOutline
from novelwriter.error import logException
from novelwriter.extensions.configlayout import NColourLabel
from novelwriter.extensions.novelselector import NovelSelector
@@ -165,8 +165,8 @@ class GuiOutlineView(QWidget):
# Public Slots
##
@pyqtSlot(str)
def updateRootItem(self, tHandle: str) -> None:
@pyqtSlot(str, Enum)
def updateRootItem(self, tHandle: str, change: nwChange) -> None:
"""Handle tasks whenever a root folders changes."""
self.outlineBar.populateNovelList()
self.outlineData.updateClasses()
@@ -380,8 +380,8 @@ class GuiOutlineTree(QTreeWidget):
self.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
self.setExpandsOnDoubleClick(False)
self.setDragEnabled(False)
self.itemDoubleClicked.connect(self._treeDoubleClick)
self.itemSelectionChanged.connect(self._itemSelected)
self.itemDoubleClicked.connect(self._onItemDoubleClicked)
self.itemSelectionChanged.connect(self._onItemSelectionChanged)
self.setIconSize(SHARED.theme.baseIconSize)
self.setIndentation(0)
@@ -563,7 +563,7 @@ class GuiOutlineTree(QTreeWidget):
##
@pyqtSlot("QTreeWidgetItem*", int)
def _treeDoubleClick(self, tItem: QTreeWidgetItem, tCol: int) -> None:
def _onItemDoubleClicked(self, tItem: QTreeWidgetItem, tCol: int) -> None:
"""Extract the handle and line number of the title double-
clicked, and send it to the main gui class for opening in the
document editor.
@@ -574,14 +574,13 @@ class GuiOutlineTree(QTreeWidget):
return
@pyqtSlot()
def _itemSelected(self) -> None:
def _onItemSelectionChanged(self) -> None:
"""Extract the handle and line number of the currently selected
title, and send it to the details panel.
"""
selItems = self.selectedItems()
if selItems:
tHandle = selItems[0].data(self._colIdx[nwOutline.TITLE], self.D_HANDLE)
sTitle = selItems[0].data(self._colIdx[nwOutline.TITLE], self.D_TITLE)
if items := self.selectedItems():
tHandle = items[0].data(self._colIdx[nwOutline.TITLE], self.D_HANDLE)
sTitle = items[0].data(self._colIdx[nwOutline.TITLE], self.D_TITLE)
self.activeItemChanged.emit(tHandle, sTitle)
return
+571 -1229
View File
File diff suppressed because it is too large Load Diff
+9 -10
View File
@@ -30,15 +30,18 @@ from time import time
from PyQt5.QtCore import Qt, pyqtSignal, pyqtSlot
from PyQt5.QtGui import QCursor, QKeyEvent
from PyQt5.QtWidgets import (
QApplication, QFrame, QHBoxLayout, QHeaderView, QLabel, QLineEdit,
QToolBar, QTreeWidget, QTreeWidgetItem, QVBoxLayout, QWidget
QApplication, QFrame, QHBoxLayout, QLabel, QLineEdit, QToolBar,
QTreeWidget, QTreeWidgetItem, QVBoxLayout, QWidget
)
from novelwriter import CONFIG, SHARED
from novelwriter.common import checkInt, cssCol
from novelwriter.core.coretools import DocSearch
from novelwriter.core.item import NWItem
from novelwriter.types import QtAlignMiddle, QtAlignRight, QtUserRole
from novelwriter.types import (
QtAlignMiddle, QtAlignRight, QtHeaderStretch, QtHeaderToContents,
QtUserRole
)
logger = logging.getLogger(__name__)
@@ -120,8 +123,8 @@ class GuiProjectSearch(QWidget):
treeHeader = self.searchResult.header()
treeHeader.setStretchLastSection(False)
treeHeader.setSectionResizeMode(self.C_NAME, QHeaderView.ResizeMode.Stretch)
treeHeader.setSectionResizeMode(self.C_COUNT, QHeaderView.ResizeMode.ResizeToContents)
treeHeader.setSectionResizeMode(self.C_NAME, QtHeaderStretch)
treeHeader.setSectionResizeMode(self.C_COUNT, QtHeaderToContents)
# Assemble
self.headerBox = QHBoxLayout()
@@ -331,15 +334,11 @@ class GuiProjectSearch(QWidget):
"""Populate the result tree."""
if results and nwItem:
tHandle = nwItem.itemHandle
docIcon = SHARED.theme.getItemIcon(
nwItem.itemType, nwItem.itemClass,
nwItem.itemLayout, nwItem.mainHeading
)
ext = "+" if capped else ""
tItem = QTreeWidgetItem()
tItem.setText(self.C_NAME, nwItem.itemName)
tItem.setIcon(self.C_NAME, docIcon)
tItem.setIcon(self.C_NAME, nwItem.getMainIcon())
tItem.setData(self.C_NAME, self.D_HANDLE, tHandle)
tItem.setText(self.C_COUNT, f"({len(results):n}{ext})")
tItem.setTextAlignment(self.C_COUNT, QtAlignRight)
+3
View File
@@ -151,6 +151,9 @@ class GuiTheme:
self.guiFont = QApplication.font()
self.guiFontB = QApplication.font()
self.guiFontB.setBold(True)
self.guiFontBU = QApplication.font()
self.guiFontBU.setBold(True)
self.guiFontBU.setUnderline(True)
self.guiFontSmall = QApplication.font()
self.guiFontSmall.setPointSizeF(0.9*self.guiFont.pointSizeF())
+54 -38
View File
@@ -44,7 +44,7 @@ from novelwriter.dialogs.about import GuiAbout
from novelwriter.dialogs.preferences import GuiPreferences
from novelwriter.dialogs.projectsettings import GuiProjectSettings
from novelwriter.dialogs.wordlist import GuiWordList
from novelwriter.enum import nwDocAction, nwDocInsert, nwDocMode, nwFocus, nwView
from novelwriter.enum import nwDocAction, nwDocInsert, nwDocMode, nwFocus, nwItemType, nwView
from novelwriter.gui.doceditor import GuiDocEditor
from novelwriter.gui.docviewer import GuiDocViewer
from novelwriter.gui.docviewerpanel import GuiDocViewerPanel
@@ -62,6 +62,7 @@ from novelwriter.tools.manuscript import GuiManuscript
from novelwriter.tools.noveldetails import GuiNovelDetails
from novelwriter.tools.welcome import GuiWelcome
from novelwriter.tools.writingstats import GuiWritingStats
from novelwriter.types import QtModShift
logger = logging.getLogger(__name__)
@@ -212,15 +213,19 @@ class GuiMain(QMainWindow):
SHARED.indexChangedTags.connect(self.docEditor.updateChangedTags)
SHARED.indexChangedTags.connect(self.docViewerPanel.updateChangedTags)
SHARED.indexCleared.connect(self.docViewerPanel.indexWasCleared)
SHARED.indexScannedText.connect(self.docViewerPanel.projectItemChanged)
SHARED.indexScannedText.connect(self.itemDetails.updateViewBox)
SHARED.indexScannedText.connect(self.projView.updateItemValues)
SHARED.mainClockTick.connect(self._timeTick)
SHARED.projectItemChanged.connect(self.docEditor.onProjectItemChanged)
SHARED.projectItemChanged.connect(self.docViewer.onProjectItemChanged)
SHARED.projectItemChanged.connect(self.docViewerPanel.onProjectItemChanged)
SHARED.projectItemChanged.connect(self.itemDetails.onProjectItemChanged)
SHARED.projectItemChanged.connect(self.projView.onProjectItemChanged)
SHARED.projectStatusChanged.connect(self.mainStatus.updateProjectStatus)
SHARED.projectStatusMessage.connect(self.mainStatus.setStatusMessage)
SHARED.rootFolderChanged.connect(self.novelView.updateRootItem)
SHARED.rootFolderChanged.connect(self.outlineView.updateRootItem)
SHARED.rootFolderChanged.connect(self.projView.updateRootItem)
SHARED.spellLanguageChanged.connect(self.mainStatus.setLanguage)
SHARED.statusLabelsChanged.connect(self.docViewerPanel.updateStatusLabels)
SHARED.statusLabelsChanged.connect(self.projView.refreshUserLabels)
self.mainMenu.requestDocAction.connect(self._passDocumentAction)
self.mainMenu.requestDocInsert.connect(self._passDocumentInsert)
@@ -233,15 +238,7 @@ class GuiMain(QMainWindow):
self.projView.openDocumentRequest.connect(self._openDocument)
self.projView.projectSettingsRequest.connect(self.showProjectSettingsDialog)
self.projView.rootFolderChanged.connect(self.novelView.updateRootItem)
self.projView.rootFolderChanged.connect(self.outlineView.updateRootItem)
self.projView.rootFolderChanged.connect(self.projView.updateRootItem)
self.projView.selectedItemChanged.connect(self.itemDetails.updateViewBox)
self.projView.treeItemChanged.connect(self.docEditor.updateDocInfo)
self.projView.treeItemChanged.connect(self.docViewer.updateDocInfo)
self.projView.treeItemChanged.connect(self.docViewerPanel.projectItemChanged)
self.projView.treeItemChanged.connect(self.itemDetails.updateViewBox)
self.projView.wordCountsChanged.connect(self._updateStatusWordCount)
self.novelView.openDocumentRequest.connect(self._openDocument)
self.novelView.selectedItemChanged.connect(self.itemDetails.updateViewBox)
@@ -250,8 +247,6 @@ class GuiMain(QMainWindow):
self.projSearch.selectedItemChanged.connect(self.itemDetails.updateViewBox)
self.docEditor.closeEditorRequest.connect(self.closeDocEditor)
self.docEditor.docCountsChanged.connect(self.itemDetails.updateCounts)
self.docEditor.docCountsChanged.connect(self.projView.updateCounts)
self.docEditor.docTextChanged.connect(self.projSearch.textChanged)
self.docEditor.editedStatusChanged.connect(self.mainStatus.updateDocumentStatus)
self.docEditor.itemHandleChanged.connect(self.novelView.setActiveHandle)
@@ -259,7 +254,8 @@ class GuiMain(QMainWindow):
self.docEditor.loadDocumentTagRequest.connect(self._followTag)
self.docEditor.novelItemMetaChanged.connect(self.novelView.updateNovelItemMeta)
self.docEditor.novelStructureChanged.connect(self.novelView.refreshTree)
self.docEditor.requestNewNoteCreation.connect(self.projView.createNewNote)
self.docEditor.openDocumentRequest.connect(self._openDocument)
self.docEditor.requestNewNoteCreation.connect(SHARED.createNewNote)
self.docEditor.requestNextDocument.connect(self.openNextDocument)
self.docEditor.requestProjectItemRenamed.connect(self.projView.renameTreeItem)
self.docEditor.requestProjectItemSelected.connect(self.projView.setSelectedHandle)
@@ -297,14 +293,25 @@ class GuiMain(QMainWindow):
self.keyReturn.setKey("Return")
self.keyReturn.activated.connect(self._keyPressReturn)
self.keyShiftReturn = QShortcut(self)
self.keyShiftReturn.setKey("Shift+Return")
self.keyShiftReturn.activated.connect(self._keyPressReturn)
self.keyEnter = QShortcut(self)
self.keyEnter.setKey("Enter")
self.keyEnter.activated.connect(self._keyPressReturn)
self.keyShiftEnter = QShortcut(self)
self.keyShiftEnter.setKey("Shift+Enter")
self.keyShiftEnter.activated.connect(self._keyPressReturn)
self.keyEscape = QShortcut(self)
self.keyEscape.setKey("Esc")
self.keyEscape.activated.connect(self._keyPressEscape)
# Internal Variables
self._lastTotalCount = 0
# Initialise Main GUI
self.initMain()
self.asProjTimer.start()
@@ -502,11 +509,9 @@ class GuiMain(QMainWindow):
def saveProject(self, autoSave: bool = False) -> bool:
"""Save the current project."""
if not SHARED.hasProject:
logger.error("No project open")
return False
self.projView.saveProjectTasks()
return SHARED.saveProject(autoSave=autoSave)
if SHARED.hasProject:
return SHARED.saveProject(autoSave=autoSave)
return False
##
# Document Actions
@@ -718,8 +723,11 @@ class GuiMain(QMainWindow):
logger.warning("No item selected")
return
if tHandle:
self.openDocument(tHandle, sTitle=sTitle, changeFocus=False, doScroll=False)
if tHandle and SHARED.project.tree.checkType(tHandle, nwItemType.FILE):
if QApplication.keyboardModifiers() == QtModShift:
self.viewDocument(tHandle)
else:
self.openDocument(tHandle, sTitle=sTitle, changeFocus=False, doScroll=False)
return
@@ -730,9 +738,8 @@ class GuiMain(QMainWindow):
QApplication.setOverrideCursor(QCursor(Qt.CursorShape.WaitCursor))
tStart = time()
self.projView.saveProjectTasks()
SHARED.project.index.rebuildIndex()
self.projView.populateTree()
SHARED.project.index.rebuild()
SHARED.project.tree.refreshAllItems()
self.novelView.refreshTree()
tEnd = time()
@@ -1050,7 +1057,7 @@ class GuiMain(QMainWindow):
))
if tree:
self.projView.populateTree()
SHARED.project.tree.refreshAllItems()
if theme:
# We are doing this manually instead of connecting to
@@ -1076,6 +1083,9 @@ class GuiMain(QMainWindow):
self.projView.initSettings()
self.novelView.initSettings()
self.outlineView.initSettings()
# Force update of word count
self._lastTotalCount = 0
self._updateStatusWordCount()
return
@@ -1213,8 +1223,10 @@ class GuiMain(QMainWindow):
self.mainStatus.setUserIdle(editIdle or userIdle)
SHARED.updateIdleTime(currTime, editIdle or userIdle)
self.mainStatus.updateTime(idleTime=SHARED.projectIdleTime)
if CONFIG.memInfo and int(currTime) % 5 == 0: # pragma: no cover
self.mainStatus.memInfo()
if int(currTime) % 5 == 0:
self._updateStatusWordCount()
if CONFIG.memInfo: # pragma: no cover
self.mainStatus.memInfo()
return
@pyqtSlot()
@@ -1242,15 +1254,19 @@ class GuiMain(QMainWindow):
if not SHARED.hasProject:
self.mainStatus.setProjectStats(0, 0)
SHARED.project.updateWordCounts()
if CONFIG.incNotesWCount:
iTotal = sum(SHARED.project.data.initCounts)
cTotal = sum(SHARED.project.data.currCounts)
self.mainStatus.setProjectStats(cTotal, cTotal - iTotal)
else:
iNovel, _ = SHARED.project.data.initCounts
cNovel, _ = SHARED.project.data.currCounts
self.mainStatus.setProjectStats(cNovel, cNovel - iNovel)
currentTotalCount = SHARED.project.currentTotalCount
if self._lastTotalCount != currentTotalCount:
self._lastTotalCount = currentTotalCount
SHARED.project.updateWordCounts()
if CONFIG.incNotesWCount:
iTotal = sum(SHARED.project.data.initCounts)
cTotal = sum(SHARED.project.data.currCounts)
self.mainStatus.setProjectStats(cTotal, cTotal - iTotal)
else:
iNovel, _ = SHARED.project.data.initCounts
cNovel, _ = SHARED.project.data.currCounts
self.mainStatus.setProjectStats(cNovel, cNovel - iNovel)
return
+53 -24
View File
@@ -26,6 +26,7 @@ from __future__ import annotations
import logging
from enum import Enum
from pathlib import Path
from time import time
from typing import TYPE_CHECKING, TypeVar
@@ -37,9 +38,11 @@ from PyQt5.QtWidgets import QFileDialog, QFontDialog, QMessageBox, QWidget
from novelwriter.common import formatFileFilter
from novelwriter.constants import nwFiles
from novelwriter.core.spellcheck import NWSpellEnchant
from novelwriter.enum import nwChange, nwItemClass
if TYPE_CHECKING: # pragma: no cover
from novelwriter.core.project import NWProject
from novelwriter.core.status import T_StatusKind
from novelwriter.gui.theme import GuiTheme
from novelwriter.guimain import GuiMain
@@ -55,15 +58,16 @@ class SharedData(QObject):
"_idleTime", "_idleRefTime",
)
focusModeChanged = pyqtSignal(bool)
indexAvailable = pyqtSignal()
indexChangedTags = pyqtSignal(list, list)
indexCleared = pyqtSignal()
mainClockTick = pyqtSignal()
projectItemChanged = pyqtSignal(str, Enum)
rootFolderChanged = pyqtSignal(str, Enum)
projectStatusChanged = pyqtSignal(bool)
projectStatusMessage = pyqtSignal(str)
spellLanguageChanged = pyqtSignal(str, str)
focusModeChanged = pyqtSignal(bool)
indexScannedText = pyqtSignal(str)
indexChangedTags = pyqtSignal(list, list)
indexCleared = pyqtSignal()
indexAvailable = pyqtSignal()
mainClockTick = pyqtSignal()
statusLabelsChanged = pyqtSignal(str)
def __init__(self) -> None:
@@ -173,10 +177,12 @@ class SharedData(QObject):
logger.debug("Thread Pool Max Count: %d", QThreadPool.globalInstance().maxThreadCount())
return
def closeEditor(self, tHandle: str | None = None) -> None:
def closeDocument(self, tHandle: str | None = None) -> None:
"""Close the document editor, optionally a specific document."""
if tHandle is None or tHandle == self.mainGui.docEditor.docHandle:
self.mainGui.closeDocument()
if tHandle is None or tHandle == self.mainGui.docViewer.docHandle:
self.mainGui.closeViewerPanel()
return
def saveEditor(self, tHandle: str | None = None) -> None:
@@ -302,30 +308,52 @@ class SharedData(QObject):
QDesktopServices.openUrl(QUrl(url))
return
@pyqtSlot(str, nwItemClass)
def createNewNote(self, tag: str, itemClass: nwItemClass) -> None:
"""Process new note request."""
self.project.createNewNote(tag, itemClass)
return
##
# Signal Proxy
# Signal Proxies
##
def indexSignalProxy(self, data: dict) -> None:
"""Emit signals on behalf of the index."""
event = data.get("event")
logger.debug("Received '%s' event from the index", event)
if event == "updateTags":
self.indexChangedTags.emit(data.get("updated", []), data.get("deleted", []))
elif event == "scanText":
self.indexScannedText.emit(data.get("handle", ""))
elif event == "clearIndex":
def emitIndexChangedTags(
self, project: NWProject, updated: list[str], deleted: list[str]
) -> None:
"""Emit the indexChangedTags signal."""
if self._project and self._project.data.uuid == project.data.uuid:
self.indexChangedTags.emit(updated, deleted)
return
def emitIndexCleared(self, project: NWProject) -> None:
"""Emit the indexCleared signal."""
if self._project and self._project.data.uuid == project.data.uuid:
self.indexCleared.emit()
elif event == "buildIndex":
return
def emitIndexAvailable(self, project: NWProject) -> None:
"""Emit the indexAvailable signal."""
if self._project and self._project.data.uuid == project.data.uuid:
self.indexAvailable.emit()
return
def projectSingalProxy(self, data: dict) -> None:
"""Emit signals on project data change."""
event = data.get("event")
logger.debug("Received '%s' event from project data", event)
if event == "statusLabels":
self.statusLabelsChanged.emit(data.get("kind", ""))
def emitStatusLabelsChanged(self, project: NWProject, kind: T_StatusKind) -> None:
"""Emit the statusLabelsChanged signal."""
if self._project and self._project.data.uuid == project.data.uuid:
self.statusLabelsChanged.emit(kind)
return
def emitProjectItemChanged(self, project: NWProject, handle: str, change: nwChange) -> None:
"""Emit the projectItemChanged signal."""
if self._project and self._project.data.uuid == project.data.uuid:
self.projectItemChanged.emit(handle, change)
return
def emitRootFolderChanged(self, project: NWProject, handle: str, change: nwChange) -> None:
"""Emit the rootFolderChanged signal."""
if self._project and self._project.data.uuid == project.data.uuid:
self.rootFolderChanged.emit(handle, change)
return
##
@@ -386,6 +414,7 @@ class SharedData(QObject):
"""Create a new project and spell checking instance."""
from novelwriter.core.project import NWProject
if isinstance(self._project, NWProject):
self._project.clear()
del self._project
del self._spelling
self._project = NWProject()
+1 -5
View File
@@ -394,13 +394,9 @@ class GuiManuscriptBuild(NDialog):
if isinstance(rItem, NWItem):
rootMap[rHandle] = rItem.itemName
itemIcon = SHARED.theme.getItemIcon(
nwItem.itemType, nwItem.itemClass,
nwItem.itemLayout, nwItem.mainHeading
)
rootName = rootMap.get(rHandle, "??????")
item = QListWidgetItem(f"{rootName}: {nwItem.itemName}")
item.setIcon(itemIcon)
item.setIcon(nwItem.getMainIcon())
self.listContent.addItem(item)
return
+12 -28
View File
@@ -31,9 +31,9 @@ from PyQt5.QtCore import QEvent, pyqtSignal, pyqtSlot
from PyQt5.QtGui import QFont, QIcon, QSyntaxHighlighter, QTextCharFormat, QTextDocument
from PyQt5.QtWidgets import (
QAbstractButton, QAbstractItemView, QDialogButtonBox, QFrame, QGridLayout,
QHBoxLayout, QHeaderView, QLabel, QLineEdit, QMenu, QPlainTextEdit,
QPushButton, QSplitter, QStackedWidget, QTreeWidget, QTreeWidgetItem,
QVBoxLayout, QWidget
QHBoxLayout, QLabel, QLineEdit, QMenu, QPlainTextEdit, QPushButton,
QSplitter, QStackedWidget, QTreeWidget, QTreeWidgetItem, QVBoxLayout,
QWidget
)
from novelwriter import CONFIG, SHARED
@@ -51,7 +51,8 @@ from novelwriter.extensions.switch import NSwitch
from novelwriter.extensions.switchbox import NSwitchBox
from novelwriter.types import (
QtAlignCenter, QtAlignLeft, QtDialogApply, QtDialogClose, QtDialogSave,
QtRoleAccept, QtRoleApply, QtRoleReject, QtUserRole
QtHeaderFixed, QtHeaderStretch, QtRoleAccept, QtRoleApply, QtRoleReject,
QtUserRole
)
if TYPE_CHECKING: # pragma: no cover
@@ -313,9 +314,9 @@ class _FilterTab(NFixedPage):
treeHeader = self.optTree.header()
treeHeader.setStretchLastSection(False)
treeHeader.setMinimumSectionSize(iPx + cMg) # See Issue #1551
treeHeader.setSectionResizeMode(self.C_NAME, QHeaderView.ResizeMode.Stretch)
treeHeader.setSectionResizeMode(self.C_ACTIVE, QHeaderView.ResizeMode.Fixed)
treeHeader.setSectionResizeMode(self.C_STATUS, QHeaderView.ResizeMode.Fixed)
treeHeader.setSectionResizeMode(self.C_NAME, QtHeaderStretch)
treeHeader.setSectionResizeMode(self.C_ACTIVE, QtHeaderFixed)
treeHeader.setSectionResizeMode(self.C_STATUS, QtHeaderFixed)
treeHeader.resizeSection(self.C_ACTIVE, iPx + cMg)
treeHeader.resizeSection(self.C_STATUS, iPx + cMg)
@@ -418,8 +419,7 @@ class _FilterTab(NFixedPage):
logger.debug("Building project tree")
self._treeMap = {}
self.optTree.clear()
for nwItem in SHARED.project.iterProjectItems():
for nwItem in SHARED.project.tree:
tHandle = nwItem.itemHandle
pHandle = nwItem.itemParent
rHandle = nwItem.itemRoot
@@ -428,28 +428,15 @@ class _FilterTab(NFixedPage):
continue
isFile = nwItem.isFileType()
isActive = nwItem.isActive
if nwItem.isInactiveClass() or not self._build.isRootAllowed(rHandle):
continue
hLevel = nwItem.mainHeading
itemIcon = SHARED.theme.getItemIcon(
nwItem.itemType, nwItem.itemClass, nwItem.itemLayout, hLevel
)
if isFile:
iconName = "checked" if isActive else "unchecked"
else:
iconName = "noncheckable"
trItem = QTreeWidgetItem()
trItem.setIcon(self.C_NAME, itemIcon)
trItem.setIcon(self.C_NAME, nwItem.getMainIcon())
trItem.setText(self.C_NAME, nwItem.itemName)
trItem.setData(self.C_DATA, self.D_HANDLE, tHandle)
trItem.setData(self.C_DATA, self.D_FILE, isFile)
trItem.setIcon(self.C_ACTIVE, SHARED.theme.getIcon(iconName))
trItem.setIcon(self.C_ACTIVE, nwItem.getActiveStatus()[1])
trItem.setTextAlignment(self.C_NAME, QtAlignLeft)
if pHandle is None and nwItem.isRootType():
@@ -496,11 +483,8 @@ class _FilterTab(NFixedPage):
self.filterOpt.addLabel(self.tr("Select Root Folders"))
for tHandle, nwItem in SHARED.project.tree.iterRoots(None):
if not nwItem.isInactiveClass():
itemIcon = SHARED.theme.getItemIcon(
nwItem.itemType, nwItem.itemClass, nwItem.itemLayout
)
self.filterOpt.addItem(
itemIcon, nwItem.itemName, f"root:{tHandle}",
nwItem.getMainIcon(), nwItem.itemName, f"root:{tHandle}",
default=self._build.isRootAllowed(tHandle)
)
+7 -1
View File
@@ -28,7 +28,7 @@ from PyQt5.QtGui import (
QColor, QFont, QPainter, QTextBlockFormat, QTextCharFormat, QTextCursor,
QTextFormat
)
from PyQt5.QtWidgets import QDialog, QDialogButtonBox, QSizePolicy, QStyle
from PyQt5.QtWidgets import QDialog, QDialogButtonBox, QHeaderView, QSizePolicy, QStyle
# Qt Alignment Flags
@@ -119,6 +119,12 @@ QtSizeIgnored = QSizePolicy.Policy.Ignored
QtSizeMinimum = QSizePolicy.Policy.Minimum
QtSizeMinimumExpanding = QSizePolicy.Policy.MinimumExpanding
# Resize Mode
QtHeaderStretch = QHeaderView.ResizeMode.Stretch
QtHeaderToContents = QHeaderView.ResizeMode.ResizeToContents
QtHeaderFixed = QHeaderView.ResizeMode.Fixed
# Scroll Bar Policy
QtScrollAlwaysOff = Qt.ScrollBarPolicy.ScrollBarAlwaysOff
+8 -4
View File
@@ -1,6 +1,6 @@
<?xml version='1.0' encoding='utf-8'?>
<novelWriterXML appVersion="2.6a3" hexVersion="0x020600a3" fileVersion="1.5" fileRevision="4" timeStamp="2024-10-28 18:19:29">
<project id="5ac0df12-8b8b-476b-9905-21d33685687c" saveCount="50" autoCount="29" editTime="2448">
<novelWriterXML appVersion="2.6b1" hexVersion="0x020600b1" fileVersion="1.5" fileRevision="4" timeStamp="2024-11-20 19:22:22">
<project id="5ac0df12-8b8b-476b-9905-21d33685687c" saveCount="51" autoCount="29" editTime="2456">
<name>Lorem Ipsum</name>
<author>lipsum.com</author>
</project>
@@ -25,13 +25,13 @@
<entry key="sedd043" count="7" red="50" green="200" blue="0" shape="SQUARE">Finished</entry>
</status>
<importance>
<entry key="i613591" count="6" red="100" green="100" blue="100" shape="SQUARE">New</entry>
<entry key="i613591" count="7" red="100" green="100" blue="100" shape="SQUARE">New</entry>
<entry key="i560cbf" count="0" red="200" green="50" blue="0" shape="SQUARE">Minor</entry>
<entry key="i37861c" count="0" red="200" green="150" blue="0" shape="SQUARE">Major</entry>
<entry key="id6b1d0" count="0" red="50" green="200" blue="0" shape="SQUARE">Main</entry>
</importance>
</settings>
<content items="21" novelWords="3115" notesWords="738">
<content items="22" novelWords="3115" notesWords="738">
<item handle="b3643d0f92e32" parent="None" root="b3643d0f92e32" order="0" type="ROOT" class="NOVEL">
<meta expanded="yes" />
<name status="sbaa94f" import="i613591">Novel</name>
@@ -116,5 +116,9 @@
<meta expanded="no" heading="H1" charCount="1770" wordCount="259" paraCount="3" cursorPos="47" />
<name status="sbaa94f" import="i613591" active="yes">Ancient Europe</name>
</item>
<item handle="1ace7ab1a0fc6" parent="None" root="1ace7ab1a0fc6" order="0" type="ROOT" class="TRASH">
<meta expanded="no" />
<name status="sbaa94f" import="i613591">Trash</name>
</item>
</content>
</novelWriterXML>
+15 -14
View File
@@ -20,7 +20,9 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
"""
from __future__ import annotations
from PyQt5.QtGui import QPixmap
from unittest.mock import MagicMock
from PyQt5.QtGui import QFont, QIcon, QPixmap
from PyQt5.QtWidgets import QWidget
@@ -28,7 +30,9 @@ class MockGuiMain(QWidget):
def __init__(self):
super().__init__()
self.mainStatus = MockStatusBar()
self.mainStatus = MagicMock()
self.docEditor = MagicMock()
self.docViewer = MagicMock()
self.projPath = ""
return
@@ -52,27 +56,24 @@ class MockGuiMain(QWidget):
return "close"
class MockStatusBar:
def __init__(self):
return
def setStatus(self, text):
return
def updateProjectStatus(self, status):
return
class MockTheme:
def __init__(self):
self.baseIconHeight = 20
self.guiFont = QFont()
self.guiFontB = QFont()
self.guiFontBU = QFont()
return
def getPixmap(self, *a):
return QPixmap()
def getIcon(self, *a) -> QIcon:
return QIcon()
def getItemIcon(self, *a, **k) -> QIcon:
return QIcon()
class MockApp:
@@ -1,5 +1,5 @@
<?xml version='1.0' encoding='utf-8'?>
<novelWriterXML appVersion="2.4rc1" hexVersion="0x020400c1" fileVersion="1.5" fileRevision="4" timeStamp="2024-04-09 22:34:16">
<novelWriterXML appVersion="2.6b1" hexVersion="0x020600b1" fileVersion="1.5" fileRevision="4" timeStamp="2024-11-23 23:32:55">
<project id="d0f3fe10-c6e6-4310-8bfd-181eb4224eed" saveCount="1" autoCount="1" editTime="0">
<name>New Project</name>
<author>Jane Doe</author>
@@ -33,35 +33,23 @@
<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">
<item handle="000000000000d" parent="0000000000008" root="0000000000008" order="1" type="FOLDER" class="NOVEL">
<meta expanded="no" />
<name status="s000000" import="i000004">New Chapter</name>
<name status="s000000" import="i000004">New Folder</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">
<item handle="000000000000f" parent="000000000000d" root="0000000000008" order="1" 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="0000000000008" root="0000000000008" order="0" type="FOLDER" class="NOVEL">
<item handle="0000000000010" parent="0000000000008" root="0000000000008" order="2" type="FOLDER" class="NOVEL">
<meta expanded="no" />
<name status="s000000" import="i000004">Stuff</name>
</item>
@@ -69,13 +57,25 @@
<meta expanded="no" heading="H2" charCount="5" wordCount="1" paraCount="0" cursorPos="0" />
<name status="s000000" import="i000004" active="yes">Hello</name>
</item>
<item handle="0000000000009" parent="None" root="0000000000009" order="1" type="ROOT" class="PLOT">
<meta expanded="no" />
<name status="s000000" import="i000004">Plot</name>
</item>
<item handle="000000000000a" parent="None" root="000000000000a" order="2" type="ROOT" class="CHARACTER">
<meta expanded="no" />
<name status="s000000" import="i000004">Characters</name>
</item>
<item handle="0000000000012" parent="000000000000a" root="000000000000a" order="0" type="FILE" class="CHARACTER" layout="NOTE">
<meta expanded="no" heading="H1" charCount="11" wordCount="3" paraCount="1" cursorPos="0" />
<name status="s000000" import="i000004" active="yes">Jane</name>
</item>
<item handle="0000000000013" parent="000000000000a" root="000000000000a" order="0" type="FILE" class="CHARACTER" layout="NOTE">
<item handle="0000000000013" parent="000000000000a" root="000000000000a" order="1" type="FILE" class="CHARACTER" layout="NOTE">
<meta expanded="no" heading="H1" charCount="11" wordCount="3" paraCount="1" cursorPos="0" />
<name status="s000000" import="i000004" active="yes">John</name>
</item>
<item handle="000000000000b" parent="None" root="000000000000b" order="3" type="ROOT" class="WORLD">
<meta expanded="no" />
<name status="s000000" import="i000004">Locations</name>
</item>
</content>
</novelWriterXML>
@@ -1,5 +1,5 @@
<?xml version='1.0' encoding='utf-8'?>
<novelWriterXML appVersion="2.4rc1" hexVersion="0x020400c1" fileVersion="1.5" fileRevision="4" timeStamp="2024-04-09 22:34:16">
<novelWriterXML appVersion="2.6b1" hexVersion="0x020600b1" fileVersion="1.5" fileRevision="4" timeStamp="2024-11-23 23:32:55">
<project id="d0f3fe10-c6e6-4310-8bfd-181eb4224eed" saveCount="1" autoCount="1" editTime="0">
<name>New Project</name>
<author>Jane Doe</author>
@@ -33,63 +33,63 @@
<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">
<item handle="000000000000d" parent="0000000000008" root="0000000000008" order="1" type="FOLDER" class="NOVEL">
<meta expanded="no" />
<name status="s000000" import="i000004">New Chapter</name>
<name status="s000000" import="i000004">New Folder</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">
<item handle="000000000000f" parent="000000000000d" root="0000000000008" order="1" 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="None" root="0000000000010" order="0" type="ROOT" class="NOVEL">
<meta expanded="no" />
<name status="s000000" import="i000004">Novel</name>
</item>
<item handle="0000000000011" parent="None" root="0000000000011" order="0" type="ROOT" class="PLOT">
<item handle="0000000000009" parent="None" root="0000000000009" order="1" type="ROOT" class="PLOT">
<meta expanded="no" />
<name status="s000000" import="i000004">Plot</name>
</item>
<item handle="0000000000012" parent="None" root="0000000000012" order="0" type="ROOT" class="CHARACTER">
<item handle="000000000000a" parent="None" root="000000000000a" order="2" type="ROOT" class="CHARACTER">
<meta expanded="no" />
<name status="s000000" import="i000004">Characters</name>
</item>
<item handle="0000000000013" parent="None" root="0000000000013" order="0" type="ROOT" class="WORLD">
<item handle="000000000000b" parent="None" root="000000000000b" order="3" type="ROOT" class="WORLD">
<meta expanded="no" />
<name status="s000000" import="i000004">Locations</name>
</item>
<item handle="0000000000014" parent="None" root="0000000000014" order="0" type="ROOT" class="TIMELINE">
<item handle="0000000000010" parent="None" root="0000000000010" order="4" type="ROOT" class="NOVEL">
<meta expanded="no" />
<name status="s000000" import="i000004">Novel</name>
</item>
<item handle="0000000000011" parent="None" root="0000000000011" order="5" type="ROOT" class="PLOT">
<meta expanded="no" />
<name status="s000000" import="i000004">Plot</name>
</item>
<item handle="0000000000012" parent="None" root="0000000000012" order="6" type="ROOT" class="CHARACTER">
<meta expanded="no" />
<name status="s000000" import="i000004">Characters</name>
</item>
<item handle="0000000000013" parent="None" root="0000000000013" order="7" type="ROOT" class="WORLD">
<meta expanded="no" />
<name status="s000000" import="i000004">Locations</name>
</item>
<item handle="0000000000014" parent="None" root="0000000000014" order="8" type="ROOT" class="TIMELINE">
<meta expanded="no" />
<name status="s000000" import="i000004">Timeline</name>
</item>
<item handle="0000000000015" parent="None" root="0000000000015" order="0" type="ROOT" class="OBJECT">
<item handle="0000000000015" parent="None" root="0000000000015" order="9" type="ROOT" class="OBJECT">
<meta expanded="no" />
<name status="s000000" import="i000004">Objects</name>
</item>
<item handle="0000000000016" parent="None" root="0000000000016" order="0" type="ROOT" class="CUSTOM">
<item handle="0000000000016" parent="None" root="0000000000016" order="10" type="ROOT" class="CUSTOM">
<meta expanded="no" />
<name status="s000000" import="i000004">Custom</name>
</item>
<item handle="0000000000017" parent="None" root="0000000000017" order="0" type="ROOT" class="CUSTOM">
<item handle="0000000000017" parent="None" root="0000000000017" order="11" type="ROOT" class="CUSTOM">
<meta expanded="no" />
<name status="s000000" import="i000004">Custom</name>
</item>
@@ -1,5 +1,5 @@
<?xml version='1.0' encoding='utf-8'?>
<novelWriterXML appVersion="2.4rc1" hexVersion="0x020400c1" fileVersion="1.5" fileRevision="4" timeStamp="2024-04-09 22:34:16">
<novelWriterXML appVersion="2.6b1" hexVersion="0x020600b1" fileVersion="1.5" fileRevision="4" timeStamp="2024-11-23 23:37:19">
<project id="d0f3fe10-c6e6-4310-8bfd-181eb4224eed" saveCount="1" autoCount="1" editTime="0">
<name>New Project</name>
<author>Jane Doe</author>
@@ -16,7 +16,7 @@
</lastHandle>
<autoReplace />
<status>
<entry key="s000000" count="15" red="100" green="100" blue="100" shape="SQUARE">New</entry>
<entry key="s000000" count="16" red="100" green="100" blue="100" shape="SQUARE">New</entry>
<entry key="s000001" count="0" red="200" green="50" blue="0" shape="SQUARE">Note</entry>
<entry key="s000002" count="0" red="200" green="150" blue="0" shape="SQUARE">Draft</entry>
<entry key="s000003" count="0" red="50" green="200" blue="0" shape="SQUARE">Finished</entry>
@@ -28,56 +28,52 @@
<entry key="i000007" count="0" red="50" green="200" blue="0" shape="SQUARE">Main</entry>
</importance>
</settings>
<content items="18" novelWords="26" notesWords="0">
<content items="19" novelWords="28" 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">
<item handle="000000000000d" parent="0000000000008" root="0000000000008" order="1" type="FOLDER" class="NOVEL">
<meta expanded="no" />
<name status="s000000" import="i000004">New Chapter</name>
<name status="s000000" import="i000004">New Folder</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">
<item handle="0000000000019" parent="000000000000d" root="0000000000008" order="1" 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="2" 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">
<item handle="0000000000010" parent="000000000000d" root="0000000000008" order="3" 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">
<item handle="000000000001a" parent="000000000000d" root="0000000000008" order="4" 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="2" type="FOLDER" class="NOVEL">
<meta expanded="no" />
<name status="s000000" import="i000004">New Chapter</name>
<name status="s000000" import="i000004">New Folder</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">
<item handle="0000000000013" parent="0000000000011" root="0000000000008" order="1" 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">
<item handle="0000000000014" parent="None" root="0000000000014" order="1" type="ROOT" class="NOVEL">
<meta expanded="no" />
<name status="s000000" import="i000004">Novel</name>
</item>
@@ -85,21 +81,29 @@
<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">
<item handle="0000000000016" parent="0000000000014" root="0000000000014" order="1" type="FOLDER" class="NOVEL">
<meta expanded="no" />
<name status="s000000" import="i000004">New Chapter</name>
<name status="s000000" import="i000004">New Folder</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">
<item handle="0000000000018" parent="0000000000016" root="0000000000014" order="1" 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 handle="0000000000009" parent="None" root="0000000000009" order="2" type="ROOT" class="PLOT">
<meta expanded="no" />
<name status="s000000" import="i000004">Plot</name>
</item>
<item handle="000000000000a" parent="None" root="000000000000a" order="3" type="ROOT" class="CHARACTER">
<meta expanded="no" />
<name status="s000000" import="i000004">Characters</name>
</item>
<item handle="000000000000b" parent="None" root="000000000000b" order="4" type="ROOT" class="WORLD">
<meta expanded="no" />
<name status="s000000" import="i000004">Locations</name>
</item>
</content>
</novelWriterXML>
@@ -1,5 +1,5 @@
<?xml version='1.0' encoding='utf-8'?>
<novelWriterXML appVersion="2.4rc1" hexVersion="0x020400c1" fileVersion="1.5" fileRevision="4" timeStamp="2024-04-09 22:34:16">
<novelWriterXML appVersion="2.6b1" hexVersion="0x020600b1" fileVersion="1.5" fileRevision="4" timeStamp="2024-11-23 19:07:52">
<project id="d0f3fe10-c6e6-4310-8bfd-181eb4224eed" saveCount="1" autoCount="0" editTime="0">
<name>Test Project A</name>
<author>Jane Doe</author>
@@ -37,7 +37,7 @@
<meta expanded="no" heading="H0" charCount="0" wordCount="0" paraCount="0" cursorPos="0" />
<name status="s000000" import="i000004" active="yes">Title Page</name>
</item>
<item handle="000000000000a" parent="0000000000008" root="0000000000008" order="0" type="FILE" class="NOVEL" layout="DOCUMENT">
<item handle="000000000000a" parent="0000000000008" root="0000000000008" order="1" type="FILE" class="NOVEL" layout="DOCUMENT">
<meta expanded="no" heading="H0" charCount="0" wordCount="0" paraCount="0" cursorPos="0" />
<name status="s000000" import="i000004" active="yes">Chapter 1</name>
</item>
@@ -45,15 +45,15 @@
<meta expanded="no" heading="H0" charCount="0" wordCount="0" paraCount="0" cursorPos="0" />
<name status="s000000" import="i000004" active="yes">Scene 1.1</name>
</item>
<item handle="000000000000c" parent="000000000000a" root="0000000000008" order="0" type="FILE" class="NOVEL" layout="DOCUMENT">
<item handle="000000000000c" parent="000000000000a" root="0000000000008" order="1" type="FILE" class="NOVEL" layout="DOCUMENT">
<meta expanded="no" heading="H0" charCount="0" wordCount="0" paraCount="0" cursorPos="0" />
<name status="s000000" import="i000004" active="yes">Scene 1.2</name>
</item>
<item handle="000000000000d" parent="000000000000a" root="0000000000008" order="0" type="FILE" class="NOVEL" layout="DOCUMENT">
<item handle="000000000000d" parent="000000000000a" root="0000000000008" order="2" type="FILE" class="NOVEL" layout="DOCUMENT">
<meta expanded="no" heading="H0" charCount="0" wordCount="0" paraCount="0" cursorPos="0" />
<name status="s000000" import="i000004" active="yes">Scene 1.3</name>
</item>
<item handle="000000000000e" parent="0000000000008" root="0000000000008" order="0" type="FILE" class="NOVEL" layout="DOCUMENT">
<item handle="000000000000e" parent="0000000000008" root="0000000000008" order="2" type="FILE" class="NOVEL" layout="DOCUMENT">
<meta expanded="no" heading="H0" charCount="0" wordCount="0" paraCount="0" cursorPos="0" />
<name status="s000000" import="i000004" active="yes">Chapter 2</name>
</item>
@@ -61,15 +61,15 @@
<meta expanded="no" heading="H0" charCount="0" wordCount="0" paraCount="0" cursorPos="0" />
<name status="s000000" import="i000004" active="yes">Scene 2.1</name>
</item>
<item handle="0000000000010" parent="000000000000e" root="0000000000008" order="0" type="FILE" class="NOVEL" layout="DOCUMENT">
<item handle="0000000000010" parent="000000000000e" root="0000000000008" order="1" type="FILE" class="NOVEL" layout="DOCUMENT">
<meta expanded="no" heading="H0" charCount="0" wordCount="0" paraCount="0" cursorPos="0" />
<name status="s000000" import="i000004" active="yes">Scene 2.2</name>
</item>
<item handle="0000000000011" parent="000000000000e" root="0000000000008" order="0" type="FILE" class="NOVEL" layout="DOCUMENT">
<item handle="0000000000011" parent="000000000000e" root="0000000000008" order="2" type="FILE" class="NOVEL" layout="DOCUMENT">
<meta expanded="no" heading="H0" charCount="0" wordCount="0" paraCount="0" cursorPos="0" />
<name status="s000000" import="i000004" active="yes">Scene 2.3</name>
</item>
<item handle="0000000000012" parent="0000000000008" root="0000000000008" order="0" type="FILE" class="NOVEL" layout="DOCUMENT">
<item handle="0000000000012" parent="0000000000008" root="0000000000008" order="3" type="FILE" class="NOVEL" layout="DOCUMENT">
<meta expanded="no" heading="H0" charCount="0" wordCount="0" paraCount="0" cursorPos="0" />
<name status="s000000" import="i000004" active="yes">Chapter 3</name>
</item>
@@ -77,15 +77,15 @@
<meta expanded="no" heading="H0" charCount="0" wordCount="0" paraCount="0" cursorPos="0" />
<name status="s000000" import="i000004" active="yes">Scene 3.1</name>
</item>
<item handle="0000000000014" parent="0000000000012" root="0000000000008" order="0" type="FILE" class="NOVEL" layout="DOCUMENT">
<item handle="0000000000014" parent="0000000000012" root="0000000000008" order="1" type="FILE" class="NOVEL" layout="DOCUMENT">
<meta expanded="no" heading="H0" charCount="0" wordCount="0" paraCount="0" cursorPos="0" />
<name status="s000000" import="i000004" active="yes">Scene 3.2</name>
</item>
<item handle="0000000000015" parent="0000000000012" root="0000000000008" order="0" type="FILE" class="NOVEL" layout="DOCUMENT">
<item handle="0000000000015" parent="0000000000012" root="0000000000008" order="2" type="FILE" class="NOVEL" layout="DOCUMENT">
<meta expanded="no" heading="H0" charCount="0" wordCount="0" paraCount="0" cursorPos="0" />
<name status="s000000" import="i000004" active="yes">Scene 3.3</name>
</item>
<item handle="0000000000016" parent="None" root="0000000000016" order="0" type="ROOT" class="PLOT">
<item handle="0000000000016" parent="None" root="0000000000016" order="1" type="ROOT" class="PLOT">
<meta expanded="no" />
<name status="s000000" import="i000004">Plot</name>
</item>
@@ -93,7 +93,7 @@
<meta expanded="no" heading="H0" charCount="0" wordCount="0" paraCount="0" cursorPos="0" />
<name status="s000000" import="i000004" active="yes">Main Plot</name>
</item>
<item handle="0000000000018" parent="None" root="0000000000018" order="0" type="ROOT" class="CHARACTER">
<item handle="0000000000018" parent="None" root="0000000000018" order="2" type="ROOT" class="CHARACTER">
<meta expanded="no" />
<name status="s000000" import="i000004">Characters</name>
</item>
@@ -101,7 +101,7 @@
<meta expanded="no" heading="H0" charCount="0" wordCount="0" paraCount="0" cursorPos="0" />
<name status="s000000" import="i000004" active="yes">Protagonist</name>
</item>
<item handle="000000000001a" parent="None" root="000000000001a" order="0" type="ROOT" class="WORLD">
<item handle="000000000001a" parent="None" root="000000000001a" order="3" type="ROOT" class="WORLD">
<meta expanded="no" />
<name status="s000000" import="i000004">Locations</name>
</item>
@@ -109,11 +109,11 @@
<meta expanded="no" heading="H0" charCount="0" wordCount="0" paraCount="0" cursorPos="0" />
<name status="s000000" import="i000004" active="yes">Main Location</name>
</item>
<item handle="000000000001c" parent="None" root="000000000001c" order="0" type="ROOT" class="ARCHIVE">
<item handle="000000000001c" parent="None" root="000000000001c" order="4" type="ROOT" class="ARCHIVE">
<meta expanded="no" />
<name status="s000000" import="i000004">Archive</name>
</item>
<item handle="000000000001d" parent="None" root="000000000001d" order="0" type="ROOT" class="TRASH">
<item handle="000000000001d" parent="None" root="000000000001d" order="5" type="ROOT" class="TRASH">
<meta expanded="no" />
<name status="s000000" import="i000004">Trash</name>
</item>
@@ -1,5 +1,5 @@
<?xml version='1.0' encoding='utf-8'?>
<novelWriterXML appVersion="2.4rc1" hexVersion="0x020400c1" fileVersion="1.5" fileRevision="4" timeStamp="2024-04-09 22:34:16">
<novelWriterXML appVersion="2.6b1" hexVersion="0x020600b1" fileVersion="1.5" fileRevision="4" timeStamp="2024-11-23 19:07:13">
<project id="d0f3fe10-c6e6-4310-8bfd-181eb4224eed" saveCount="1" autoCount="0" editTime="0">
<name>Test Project B</name>
<author>Jane Doe</author>
@@ -37,31 +37,31 @@
<meta expanded="no" heading="H0" charCount="0" wordCount="0" paraCount="0" cursorPos="0" />
<name status="s000000" import="i000004" active="yes">Title Page</name>
</item>
<item handle="000000000000a" parent="0000000000008" root="0000000000008" order="0" type="FILE" class="NOVEL" layout="DOCUMENT">
<item handle="000000000000a" parent="0000000000008" root="0000000000008" order="1" type="FILE" class="NOVEL" layout="DOCUMENT">
<meta expanded="no" heading="H0" charCount="0" wordCount="0" paraCount="0" cursorPos="0" />
<name status="s000000" import="i000004" active="yes">Scene 1</name>
</item>
<item handle="000000000000b" parent="0000000000008" root="0000000000008" order="0" type="FILE" class="NOVEL" layout="DOCUMENT">
<item handle="000000000000b" parent="0000000000008" root="0000000000008" order="2" type="FILE" class="NOVEL" layout="DOCUMENT">
<meta expanded="no" heading="H0" charCount="0" wordCount="0" paraCount="0" cursorPos="0" />
<name status="s000000" import="i000004" active="yes">Scene 2</name>
</item>
<item handle="000000000000c" parent="0000000000008" root="0000000000008" order="0" type="FILE" class="NOVEL" layout="DOCUMENT">
<item handle="000000000000c" parent="0000000000008" root="0000000000008" order="3" type="FILE" class="NOVEL" layout="DOCUMENT">
<meta expanded="no" heading="H0" charCount="0" wordCount="0" paraCount="0" cursorPos="0" />
<name status="s000000" import="i000004" active="yes">Scene 3</name>
</item>
<item handle="000000000000d" parent="0000000000008" root="0000000000008" order="0" type="FILE" class="NOVEL" layout="DOCUMENT">
<item handle="000000000000d" parent="0000000000008" root="0000000000008" order="4" type="FILE" class="NOVEL" layout="DOCUMENT">
<meta expanded="no" heading="H0" charCount="0" wordCount="0" paraCount="0" cursorPos="0" />
<name status="s000000" import="i000004" active="yes">Scene 4</name>
</item>
<item handle="000000000000e" parent="0000000000008" root="0000000000008" order="0" type="FILE" class="NOVEL" layout="DOCUMENT">
<item handle="000000000000e" parent="0000000000008" root="0000000000008" order="5" type="FILE" class="NOVEL" layout="DOCUMENT">
<meta expanded="no" heading="H0" charCount="0" wordCount="0" paraCount="0" cursorPos="0" />
<name status="s000000" import="i000004" active="yes">Scene 5</name>
</item>
<item handle="000000000000f" parent="0000000000008" root="0000000000008" order="0" type="FILE" class="NOVEL" layout="DOCUMENT">
<item handle="000000000000f" parent="0000000000008" root="0000000000008" order="6" type="FILE" class="NOVEL" layout="DOCUMENT">
<meta expanded="no" heading="H0" charCount="0" wordCount="0" paraCount="0" cursorPos="0" />
<name status="s000000" import="i000004" active="yes">Scene 6</name>
</item>
<item handle="0000000000010" parent="None" root="0000000000010" order="0" type="ROOT" class="PLOT">
<item handle="0000000000010" parent="None" root="0000000000010" order="1" type="ROOT" class="PLOT">
<meta expanded="no" />
<name status="s000000" import="i000004">Plot</name>
</item>
@@ -69,7 +69,7 @@
<meta expanded="no" heading="H0" charCount="0" wordCount="0" paraCount="0" cursorPos="0" />
<name status="s000000" import="i000004" active="yes">Main Plot</name>
</item>
<item handle="0000000000012" parent="None" root="0000000000012" order="0" type="ROOT" class="CHARACTER">
<item handle="0000000000012" parent="None" root="0000000000012" order="2" type="ROOT" class="CHARACTER">
<meta expanded="no" />
<name status="s000000" import="i000004">Characters</name>
</item>
@@ -77,7 +77,7 @@
<meta expanded="no" heading="H0" charCount="0" wordCount="0" paraCount="0" cursorPos="0" />
<name status="s000000" import="i000004" active="yes">Protagonist</name>
</item>
<item handle="0000000000014" parent="None" root="0000000000014" order="0" type="ROOT" class="WORLD">
<item handle="0000000000014" parent="None" root="0000000000014" order="3" type="ROOT" class="WORLD">
<meta expanded="no" />
<name status="s000000" import="i000004">Locations</name>
</item>
@@ -85,11 +85,11 @@
<meta expanded="no" heading="H0" charCount="0" wordCount="0" paraCount="0" cursorPos="0" />
<name status="s000000" import="i000004" active="yes">Main Location</name>
</item>
<item handle="0000000000016" parent="None" root="0000000000016" order="0" type="ROOT" class="ARCHIVE">
<item handle="0000000000016" parent="None" root="0000000000016" order="4" type="ROOT" class="ARCHIVE">
<meta expanded="no" />
<name status="s000000" import="i000004">Archive</name>
</item>
<item handle="0000000000017" parent="None" root="0000000000017" order="0" type="ROOT" class="TRASH">
<item handle="0000000000017" parent="None" root="0000000000017" order="5" type="ROOT" class="TRASH">
<meta expanded="no" />
<name status="s000000" import="i000004">Trash</name>
</item>
@@ -1,10 +1,10 @@
<?xml version='1.0' encoding='utf-8'?>
<cp:coreProperties xmlns:cp="http://schemas.openxmlformats.org/package/2006/metadata/core-properties" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:dcterms="http://purl.org/dc/terms/" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<dcterms:created xsi:type="dcterms:W3CDTF">2024-10-28T20:12:57</dcterms:created>
<dcterms:modified xsi:type="dcterms:W3CDTF">2024-10-28T20:12:57</dcterms:modified>
<dcterms:created xsi:type="dcterms:W3CDTF">2024-11-20T19:45:15</dcterms:created>
<dcterms:modified xsi:type="dcterms:W3CDTF">2024-11-20T19:45:15</dcterms:modified>
<dc:creator>lipsum.com</dc:creator>
<dc:title>Lorem Ipsum</dc:title>
<dc:language>en_GB</dc:language>
<cp:revision>50</cp:revision>
<cp:revision>51</cp:revision>
<cp:lastModifiedBy>lipsum.com</cp:lastModifiedBy>
</cp:coreProperties>
@@ -1,10 +0,0 @@
%%~name: New Note
%%~path: 000000000000a/0000000000010
%%~kind: CHARACTER/NOTE
%%~hash: 9fae6dfdd3d1c0822d3a3cf90c0142e65ad8e557
%%~date: 2023-08-25 18:14:24/2023-08-25 18:14:24
# Jane Doe
@tag: Jane
This is a file about Jane.
@@ -1,10 +1,10 @@
%%~name: New Note
%%~path: 0000000000009/0000000000011
%%~kind: PLOT/NOTE
%%~hash: 3d3697638a70fc86cc023df6d42202a262d93905
%%~date: 2024-04-14 23:46:49/2024-04-14 23:46:49
# Main Plot
%%~path: 000000000000a/0000000000011
%%~kind: CHARACTER/NOTE
%%~hash: 9fae6dfdd3d1c0822d3a3cf90c0142e65ad8e557
%%~date: 2024-11-23 18:26:00/2024-11-23 18:26:00
# Jane Doe
@tag: MainPlot
@tag: Jane
This is a file [i]detailing[/i] the main plot.
This is a file about Jane.
@@ -1,10 +1,10 @@
%%~name: New Note
%%~path: 000000000000b/0000000000012
%%~kind: WORLD/NOTE
%%~hash: 3f5c3c6c3ba1c27c30b8ac9e59c222fb9a1bd775
%%~date: 2023-08-25 18:17:45/2023-08-25 18:17:45
# Main Location
%%~path: 0000000000009/0000000000012
%%~kind: PLOT/NOTE
%%~hash: 3d3697638a70fc86cc023df6d42202a262d93905
%%~date: 2024-11-23 18:26:31/2024-11-23 18:26:31
# Main Plot
@tag: Home
@tag: MainPlot
This is a file describing Janes home.
This is a file [i]detailing[/i] the main plot.
@@ -0,0 +1,10 @@
%%~name: New Note
%%~path: 000000000000b/0000000000013
%%~kind: WORLD/NOTE
%%~hash: 3f5c3c6c3ba1c27c30b8ac9e59c222fb9a1bd775
%%~date: 2024-11-23 18:28:00/2024-11-23 18:28:00
# Main Location
@tag: Home
This is a file describing Janes home.
@@ -1,6 +1,6 @@
<?xml version='1.0' encoding='utf-8'?>
<novelWriterXML appVersion="2.6a3" hexVersion="0x020600a3" fileVersion="1.5" fileRevision="4" timeStamp="2024-11-01 21:15:11">
<project id="d0f3fe10-c6e6-4310-8bfd-181eb4224eed" saveCount="3" autoCount="2" editTime="5">
<novelWriterXML appVersion="2.6b1" hexVersion="0x020600b1" fileVersion="1.5" fileRevision="4" timeStamp="2024-11-23 23:35:44">
<project id="d0f3fe10-c6e6-4310-8bfd-181eb4224eed" saveCount="3" autoCount="2" editTime="4">
<name>New Project</name>
<author>Jane Doe</author>
</project>
@@ -22,13 +22,13 @@
<entry key="s000003" count="0" red="50" green="200" blue="0" shape="SQUARE">Finished</entry>
</status>
<importance>
<entry key="i000004" count="6" red="100" green="100" blue="100" shape="SQUARE">New</entry>
<entry key="i000004" count="7" red="100" green="100" blue="100" shape="SQUARE">New</entry>
<entry key="i000005" count="0" red="200" green="50" blue="0" shape="SQUARE">Minor</entry>
<entry key="i000006" count="0" red="200" green="150" blue="0" shape="SQUARE">Major</entry>
<entry key="i000007" count="0" red="50" green="200" blue="0" shape="SQUARE">Main</entry>
</importance>
</settings>
<content items="11" novelWords="179" notesWords="27">
<content items="12" novelWords="179" notesWords="27">
<item handle="0000000000008" parent="None" root="0000000000008" order="0" type="ROOT" class="NOVEL">
<meta expanded="yes" />
<name status="s000000" import="i000004">Novel</name>
@@ -39,7 +39,7 @@
</item>
<item handle="000000000000d" parent="0000000000008" root="0000000000008" order="1" type="FOLDER" class="NOVEL">
<meta expanded="yes" />
<name status="s000000" import="i000004">New Chapter</name>
<name status="s000000" import="i000004">New Folder</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" />
@@ -50,28 +50,32 @@
<name status="s000000" import="i000004" active="yes">New Scene</name>
</item>
<item handle="0000000000009" parent="None" root="0000000000009" order="1" type="ROOT" class="PLOT">
<meta expanded="yes" />
<meta expanded="no" />
<name status="s000000" import="i000004">Plot</name>
</item>
<item handle="0000000000011" parent="0000000000009" root="0000000000009" order="0" type="FILE" class="PLOT" layout="NOTE">
<item handle="0000000000012" parent="0000000000009" root="0000000000009" order="0" type="FILE" class="PLOT" layout="NOTE">
<meta expanded="no" heading="H1" charCount="48" wordCount="10" paraCount="1" cursorPos="76" />
<name status="s000000" import="i000004" active="yes">New Note</name>
</item>
<item handle="000000000000a" parent="None" root="000000000000a" order="2" type="ROOT" class="CHARACTER">
<meta expanded="yes" />
<meta expanded="no" />
<name status="s000000" import="i000004">Characters</name>
</item>
<item handle="0000000000010" parent="000000000000a" root="000000000000a" order="0" type="FILE" class="CHARACTER" layout="NOTE">
<item handle="0000000000011" parent="000000000000a" root="000000000000a" order="0" type="FILE" class="CHARACTER" layout="NOTE">
<meta expanded="no" heading="H1" charCount="34" wordCount="8" paraCount="1" cursorPos="51" />
<name status="s000000" import="i000004" active="yes">New Note</name>
</item>
<item handle="000000000000b" parent="None" root="000000000000b" order="3" type="ROOT" class="WORLD">
<meta expanded="yes" />
<name status="s000000" import="i000004">World</name>
<meta expanded="no" />
<name status="s000000" import="i000004">Locations</name>
</item>
<item handle="0000000000012" parent="000000000000b" root="000000000000b" order="0" type="FILE" class="WORLD" layout="NOTE">
<item handle="0000000000013" parent="000000000000b" root="000000000000b" order="0" type="FILE" class="WORLD" layout="NOTE">
<meta expanded="no" heading="H1" charCount="51" wordCount="9" paraCount="1" cursorPos="68" />
<name status="s000000" import="i000004" active="yes">New Note</name>
</item>
<item handle="0000000000010" parent="None" root="0000000000010" order="4" type="ROOT" class="TRASH">
<meta expanded="no" />
<name status="s000000" import="i000004">Trash</name>
</item>
</content>
</novelWriterXML>
@@ -1,5 +1,5 @@
<?xml version='1.0' encoding='utf-8'?>
<novelWriterXML appVersion="2.4rc1" hexVersion="0x020400c1" fileVersion="1.5" fileRevision="4" timeStamp="2024-04-09 22:31:14">
<novelWriterXML appVersion="2.6b1" hexVersion="0x020600b1" fileVersion="1.5" fileRevision="4" timeStamp="2024-11-23 17:46:20">
<project id="d0f3fe10-c6e6-4310-8bfd-181eb4224eed" saveCount="2" autoCount="1" editTime="0">
<name>New Project</name>
<author>Jane Doe</author>
@@ -39,7 +39,7 @@
</item>
<item handle="000000000000d" parent="0000000000008" root="0000000000008" order="1" type="FOLDER" class="NOVEL">
<meta expanded="no" />
<name status="s000000" import="i000004">New Chapter</name>
<name status="s000000" import="i000004">New Folder</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" />
@@ -59,7 +59,7 @@
</item>
<item handle="000000000000b" parent="None" root="000000000000b" order="3" type="ROOT" class="WORLD">
<meta expanded="no" />
<name status="s000000" import="i000004">World</name>
<name status="s000000" import="i000004">Locations</name>
</item>
</content>
</novelWriterXML>
+1 -1
View File
@@ -223,7 +223,7 @@ def testCoreBuildSettings_Filters(mockGUI, fncPath: Path, mockRnd):
build = BuildSettings()
# Add some more items
hArchRoot = project.newRoot(nwItemClass.ARCHIVE, "Archive")
hArchRoot = project.newRoot(nwItemClass.ARCHIVE)
hPlotDoc = project.newFile("Main Plot", C.hPlotRoot)
hCharDoc = project.newFile("Jane Doe", C.hCharRoot)
+67 -66
View File
@@ -54,33 +54,42 @@ def testCoreTools_DocMerger(monkeypatch, mockGUI, fncPath, tstPaths, mockRnd, ip
# =====================
hChapter1 = project.newFile("Chapter 1", C.hNovelRoot)
hSceneOne11 = project.newFile("Scene 1.1", hChapter1) # type: ignore
hSceneOne12 = project.newFile("Scene 1.2", hChapter1) # type: ignore
hSceneOne13 = project.newFile("Scene 1.3", hChapter1) # type: ignore
assert hChapter1 is not None
hSceneOne11 = project.newFile("Scene 1.1", hChapter1)
hSceneOne12 = project.newFile("Scene 1.2", hChapter1)
hSceneOne13 = project.newFile("Scene 1.3", hChapter1)
assert hSceneOne11 is not None
assert hSceneOne12 is not None
assert hSceneOne13 is not None
docText1 = "\n\n".join(ipsumText[0:2]) + "\n\n"
docText2 = "\n\n".join(ipsumText[1:3]) + "\n\n"
docText3 = "\n\n".join(ipsumText[2:4]) + "\n\n"
docText4 = "\n\n".join(ipsumText[3:5]) + "\n\n"
project.writeNewFile(hChapter1, 2, True, docText1) # type: ignore
project.writeNewFile(hSceneOne11, 3, True, docText2) # type: ignore
project.writeNewFile(hSceneOne12, 3, True, docText3) # type: ignore
project.writeNewFile(hSceneOne13, 3, True, docText4) # type: ignore
project.writeNewFile(hChapter1, 2, True, docText1)
project.writeNewFile(hSceneOne11, 3, True, docText2)
project.writeNewFile(hSceneOne12, 3, True, docText3)
project.writeNewFile(hSceneOne13, 3, True, docText4)
# Basic Checks
# ============
docMerger = DocMerger(project)
assert docMerger.targetHandle is None
# No writing without a target set
assert docMerger.writeTargetDoc() is False
assert docMerger.targetHandle is None
# Cannot append invalid handle
assert docMerger.appendText(C.hInvalid, True, "Merge") is False
docMerger.appendText(C.hInvalid, True, "Merge")
assert docMerger._text == []
# Cannot create new target from invalid handle
assert docMerger.newTargetDoc(C.hInvalid, "Test") is None
docMerger.newTargetDoc(C.hInvalid, "Test")
assert docMerger.targetHandle is None
# Merge to New
# ============
@@ -89,12 +98,17 @@ def testCoreTools_DocMerger(monkeypatch, mockGUI, fncPath, tstPaths, mockRnd, ip
testFile = tstPaths.outDir / "coreDocTools_DocMerger_0000000000014.nwd"
compFile = tstPaths.refDir / "coreDocTools_DocMerger_0000000000014.nwd"
assert docMerger.newTargetDoc(hChapter1, "All of Chapter 1") == "0000000000014" # type: ignore
docMerger.newTargetDoc(hChapter1, "All of Chapter 1")
assert docMerger.targetHandle == "0000000000014"
assert docMerger.appendText(hChapter1, True, "Merge") is True # type: ignore
assert docMerger.appendText(hSceneOne11, True, "Merge") is True # type: ignore
assert docMerger.appendText(hSceneOne12, True, "Merge") is True # type: ignore
assert docMerger.appendText(hSceneOne13, True, "Merge") is True # type: ignore
docMerger.appendText(hChapter1, True, "Merge")
assert len(docMerger._text) == 1
docMerger.appendText(hSceneOne11, True, "Merge")
assert len(docMerger._text) == 2
docMerger.appendText(hSceneOne12, True, "Merge")
assert len(docMerger._text) == 3
docMerger.appendText(hSceneOne13, True, "Merge")
assert len(docMerger._text) == 4
# Block writing and check error handling
with monkeypatch.context() as mp:
@@ -115,11 +129,14 @@ def testCoreTools_DocMerger(monkeypatch, mockGUI, fncPath, tstPaths, mockRnd, ip
testFile = tstPaths.outDir / "coreDocTools_DocMerger_0000000000010.nwd"
compFile = tstPaths.refDir / "coreDocTools_DocMerger_0000000000010.nwd"
docMerger.setTargetDoc(hChapter1) # type: ignore
docMerger.setTargetDoc(hChapter1)
assert docMerger.appendText(hSceneOne11, True, "Merge") is True # type: ignore
assert docMerger.appendText(hSceneOne12, True, "Merge") is True # type: ignore
assert docMerger.appendText(hSceneOne13, True, "Merge") is True # type: ignore
docMerger.appendText(hSceneOne11, True, "Merge")
assert len(docMerger._text) == 1
docMerger.appendText(hSceneOne12, True, "Merge")
assert len(docMerger._text) == 2
docMerger.appendText(hSceneOne13, True, "Merge")
assert len(docMerger._text) == 3
assert docMerger.writeTargetDoc() is True
copyfile(saveFile, testFile)
@@ -180,50 +197,46 @@ def testCoreTools_DocSplitter(monkeypatch, mockGUI, fncPath, mockRnd, ipsumText)
docSplitter.splitDocument(splitData, docRaw) # type: ignore
for i, (lineNo, hLevel, hLabel) in enumerate(splitData):
assert docSplitter._rawData[i] == (docRaw[lineNo:lineNo+4], hLevel, hLabel)
assert project.tree.subTree(C.hNovelRoot) == [
"000000000000c", "000000000000d", "000000000000e", "000000000000f", "0000000000010",
]
# Test flat split into same parent
docSplitter.setParentItem(C.hNovelRoot)
assert docSplitter._inFolder is False
assert docSplitter._parHandle is not None
# Cause write error on all chunks
with monkeypatch.context() as mp:
mp.setattr("builtins.open", causeOSError)
resStatus = []
for status, _, _ in docSplitter.writeDocuments(False):
resStatus.append(status)
assert not any(resStatus)
assert not any(docSplitter.writeDocuments(False))
assert docSplitter.getError() == "OSError: Mock OSError"
assert project.tree.subTree(C.hNovelRoot) == [
"000000000000c", "000000000000d", "000000000000e", "000000000000f", "0000000000010",
"0000000000011", "0000000000012", "0000000000013", "0000000000014", "0000000000015",
"0000000000016", "0000000000017", "0000000000018", "0000000000019", "000000000001a",
]
# Generate as flat structure in root folder
resStatus = []
resDocHandle = []
resNearHandle = []
for status, dHandle, nHandle in docSplitter.writeDocuments(False):
resStatus.append(status)
resDocHandle.append(dHandle)
resNearHandle.append(nHandle)
resStatus = list(docSplitter.writeDocuments(False))
resDocHandle = project.tree.subTree(C.hNovelRoot)
assert all(resStatus)
assert resDocHandle == [
"000000000000c", "000000000000d", "000000000000e", "000000000000f", "0000000000010",
"0000000000011", "0000000000012", "0000000000013", "0000000000014", "0000000000015",
"0000000000016", "0000000000017", "0000000000018", "0000000000019", "000000000001a",
"000000000001b", "000000000001c", "000000000001d", "000000000001e", "000000000001f",
"0000000000020", "0000000000021", "0000000000022", "0000000000023", "0000000000024",
]
assert resNearHandle == [ # Each document should be next to the previous one
hSplitDoc, "000000000001b", "000000000001c", "000000000001d", "000000000001e",
"000000000001f", "0000000000020", "0000000000021", "0000000000022", "0000000000023",
]
# Generate as hierarchy in new folder
hSplitFolder = docSplitter.newParentFolder(C.hNovelRoot, "Split Folder")
docSplitter.newParentFolder(C.hNovelRoot, "Split Folder")
assert docSplitter._inFolder is True
assert docSplitter._parHandle is not None
resStatus = []
resDocHandle = []
resNearHandle = []
for status, dHandle, nHandle in docSplitter.writeDocuments(True):
resStatus.append(status)
resDocHandle.append(dHandle)
resNearHandle.append(nHandle)
resStatus = list(docSplitter.writeDocuments(True))
resDocHandle = project.tree.subTree(docSplitter._parHandle)
assert all(resStatus)
assert resDocHandle == [
@@ -238,18 +251,6 @@ def testCoreTools_DocSplitter(monkeypatch, mockGUI, fncPath, mockRnd, ipsumText)
"000000000002e", # Scene Four
"000000000002f", # Scene Five
]
assert resNearHandle == [
hSplitFolder, # Part One is after Split Folder
"0000000000026", # Chapter One is after Part One
"0000000000027", # Scene One is after Chapter One
"0000000000028", # Section One is after Scene One
"0000000000029", # Section Two is after Section One
"0000000000028", # Scene Two is after Scene One
"0000000000027", # Chapter Two is after Chapter One
"000000000002c", # Scene Three is after Chapter Two
"000000000002d", # Scene Four is after Scene Three
"000000000002e", # Scene Five is after Scene Four
]
# Check that status and importance has been preserved
for rHandle in resDocHandle:
@@ -292,9 +293,9 @@ def testCoreTools_DocDuplicator(mockGUI, fncPath, tstPaths, mockRnd):
# A new copy is created
assert list(dup.duplicate([C.hSceneDoc])) == [
("0000000000010", C.hSceneDoc), # The Scene
"0000000000010" # The Scene
]
assert project.tree._order == [
assert list(project.tree._items.keys()) == [
C.hNovelRoot, C.hPlotRoot, C.hCharRoot, C.hWorldRoot,
C.hTitlePage, C.hChapterDir, C.hChapterDoc, C.hSceneDoc,
"0000000000010",
@@ -311,11 +312,11 @@ def testCoreTools_DocDuplicator(mockGUI, fncPath, tstPaths, mockRnd):
# 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
"0000000000011", # The Folder
"0000000000012", # The Chapter
"0000000000013", # The Scene
]
assert project.tree._order == [
assert list(project.tree._items.keys()) == [
C.hNovelRoot, C.hPlotRoot, C.hCharRoot, C.hWorldRoot,
C.hTitlePage, C.hChapterDir, C.hChapterDoc, C.hSceneDoc,
"0000000000010",
@@ -340,13 +341,13 @@ def testCoreTools_DocDuplicator(mockGUI, fncPath, tstPaths, mockRnd):
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
"0000000000014", # The Root
"0000000000015", # The Title Page
"0000000000016", # The Folder
"0000000000017", # The Chapter
"0000000000018", # The Scene
]
assert project.tree._order == [
assert list(project.tree._items.keys()) == [
C.hNovelRoot, C.hPlotRoot, C.hCharRoot, C.hWorldRoot,
C.hTitlePage, C.hChapterDir, C.hChapterDoc, C.hSceneDoc,
"0000000000010",
@@ -388,7 +389,7 @@ def testCoreTools_DocDuplicator(mockGUI, fncPath, tstPaths, mockRnd):
assert isinstance(content, Path)
(content / "0000000000019.nwd").touch()
assert (content / "0000000000019.nwd").exists()
assert list(dup.duplicate([C.hChapterDoc, C.hSceneDoc])) == []
assert list(dup.duplicate([C.hChapterDoc, C.hSceneDoc])) == ["0000000000019", "000000000001a"]
# Save and Close
project.saveProject()
+9 -15
View File
@@ -93,7 +93,7 @@ def testCoreDocBuild_OpenDocument(monkeypatch, mockGUI, prjLipsum, fncPath, tstP
assert docBuild._outline is True
assert len(docBuild) == 21
assert len(docBuild) == 22
# Check FODT Build
# ================
@@ -151,7 +151,7 @@ def testCoreDocBuild_OpenDocument(monkeypatch, mockGUI, prjLipsum, fncPath, tstP
with monkeypatch.context() as mp:
mp.setattr("novelwriter.formats.toodt.ToOdt.doConvert", causeException)
assert len(docBuild) == 21
assert len(docBuild) == 22
count = 0
error = []
@@ -196,7 +196,7 @@ def testCoreDocBuild_HTML(monkeypatch, mockGUI, prjLipsum, fncPath, tstPaths):
docBuild = NWBuildDocument(project, build)
docBuild.queueAll()
assert len(docBuild) == 21
assert len(docBuild) == 22
# Check HTML5 Build
# =================
@@ -264,7 +264,7 @@ def testCoreDocBuild_Markdown(monkeypatch, mockGUI, prjLipsum, fncPath, tstPaths
docBuild = NWBuildDocument(project, build)
docBuild.queueAll()
assert len(docBuild) == 21
assert len(docBuild) == 22
# Check Standard Markdown Build
# =============================
@@ -332,7 +332,7 @@ def testCoreDocBuild_DocX(mockGUI, prjLipsum, fncPath):
docBuild = NWBuildDocument(project, build)
docBuild.queueAll()
assert len(docBuild) == 21
assert len(docBuild) == 22
# Check Build
# ===========
@@ -365,7 +365,7 @@ def testCoreDocBuild_PDF(mockGUI, prjLipsum, fncPath):
docBuild = NWBuildDocument(project, build)
docBuild.queueAll()
assert len(docBuild) == 21
assert len(docBuild) == 22
# Check Build
# ===========
@@ -397,7 +397,7 @@ def testCoreDocBuild_NWD(mockGUI, prjLipsum, fncPath, tstPaths):
docBuild = NWBuildDocument(project, build)
docBuild.queueAll()
assert len(docBuild) == 21
assert len(docBuild) == 22
# Check NWD Build
# ===============
@@ -474,8 +474,8 @@ def testCoreDocBuild_Custom(mockGUI, fncPath: Path):
# Add an invalid item to the project
nHandle = "0123456789def"
project.tree._order.append(nHandle)
project.tree._tree[nHandle] = None # type: ignore
project.tree._items[nHandle] = None # type: ignore
project.tree._nodes[nHandle] = None # type: ignore
docBuild.queueAll()
assert len(docBuild) == 8
@@ -517,12 +517,6 @@ def testCoreDocBuild_IterBuild(mockGUI, fncPath: Path, mockRnd):
project.storage.getDocument(hPlotDoc).writeDocument("# Main Plot\n**Text**")
project.storage.getDocument(hCharDoc).writeDocument("# Jane Doe\n~~Text~~")
# Fix project order as this has never been opened in a GUI
project.tree.setOrder([ # type: ignore
C.hNovelRoot, C.hTitlePage, C.hChapterDir, C.hChapterDoc, C.hSceneDoc,
C.hPlotRoot, hPlotDoc, C.hCharRoot, hCharDoc, C.hWorldRoot
])
docBuild = NWBuildDocument(project, build)
docBuild.queueAll()
assert len(docBuild) == 10
+23 -13
View File
@@ -59,6 +59,7 @@ def testCoreIndex_LoadSave(qtbot, monkeypatch, prjLipsum, mockGUI, tstPaths):
"67a8707f2f249": False, # Character ROOT
"6c6afb1247750": False, # Plot ROOT
"60bdf227455cc": False, # World ROOT
"1ace7ab1a0fc6": False, # Trash ROOT
}
for tItem in project.tree:
index.reIndexHandle(tItem.itemHandle)
@@ -89,7 +90,7 @@ def testCoreIndex_LoadSave(qtbot, monkeypatch, prjLipsum, mockGUI, tstPaths):
assert index._itemIndex["4c4f28287af27"] is None
# Clear the index
index.clearIndex()
index.clear()
assert index._tagsIndex._tags == {}
assert index._itemIndex._items == {}
@@ -112,8 +113,8 @@ def testCoreIndex_LoadSave(qtbot, monkeypatch, prjLipsum, mockGUI, tstPaths):
assert str(index._itemIndex.packData()) == itemsIndex
# Rebuild index
index.clearIndex()
index.rebuildIndex()
index.clear()
index.rebuild()
assert str(index._tagsIndex.packData()) == tagIndex
assert str(index._itemIndex.packData()) == itemsIndex
@@ -152,6 +153,7 @@ def testCoreIndex_LoadSave(qtbot, monkeypatch, prjLipsum, mockGUI, tstPaths):
assert "7a992350f3eb6" in index._itemIndex
# Close Project
SHARED._project = project # Otherwise the signal is not emitted
with qtbot.waitSignal(SHARED.indexCleared, timeout=1000):
# Regression test for issue #1718
project.closeProject()
@@ -220,7 +222,7 @@ def testCoreIndex_CheckThese(mockGUI, fncPath, mockRnd):
mockRnd.reset()
buildTestProject(project, fncPath)
index = project.index
index.clearIndex()
index.clear()
nHandle = project.newFile("Hello", C.hNovelRoot)
cHandle = project.newFile("Jane", C.hCharRoot)
@@ -346,6 +348,7 @@ def testCoreIndex_ScanText(monkeypatch, mockGUI, fncPath, mockRnd):
# Some items for fail to scan tests
dHandle = project.newFolder("Folder", C.hNovelRoot)
xHandle = project.newFile("No Layout", C.hNovelRoot)
xIndex = project.tree.model.indexFromHandle(xHandle)
assert isinstance(dHandle, str)
assert isinstance(xHandle, str)
@@ -363,10 +366,13 @@ def testCoreIndex_ScanText(monkeypatch, mockGUI, fncPath, mockRnd):
assert index.scanText(xHandle, "Hello World!") is False
# Create the trash folder
tHandle = project.trashFolder()
tNode = project.tree.trash
assert tNode is not None
tIndex = project.tree.model.indexFromNode(tNode)
tHandle = tNode.item.itemHandle
assert project.tree[tHandle] is not None
xItem.setParent(tHandle)
project.tree.updateItemData(xItem.itemHandle)
project.tree.model.multiMove([xIndex], tIndex)
assert xItem.itemRoot == tHandle
assert xItem.itemClass == nwItemClass.TRASH
assert index.scanText(xHandle, "## Hello World!") is True
@@ -374,9 +380,10 @@ def testCoreIndex_ScanText(monkeypatch, mockGUI, fncPath, mockRnd):
# Create the archive root
aHandle = project.newRoot(nwItemClass.ARCHIVE)
aIndex = project.tree.model.indexFromHandle(aHandle)
assert project.tree[aHandle] is not None
xItem.setParent(aHandle)
project.tree.updateItemData(xItem.itemHandle)
project.tree.model.multiMove([xIndex], aIndex)
assert index.scanText(xHandle, "### Hello World!") is True
assert xItem.mainHeading == "H3"
@@ -901,7 +908,8 @@ def testCoreIndex_ExtractData(mockGUI, fncPath, mockRnd):
]
# Add a fake handle to the tree and check that it's ignored
project.tree._order.append("0000000000000")
project.tree._items["0000000000000"] = None # type: ignore
project.tree._nodes["0000000000000"] = None # type: ignore
assert [(h, t) for h, t, _ in index._itemIndex.iterNovelStructure(activeOnly=False)] == [
(C.hTitlePage, "T0001"),
(C.hChapterDoc, "T0001"),
@@ -912,7 +920,8 @@ def testCoreIndex_ExtractData(mockGUI, fncPath, mockRnd):
(sHandle, "T0001"),
(tHandle, "T0001"),
]
project.tree._order.remove("0000000000000")
del project.tree._items["0000000000000"]
del project.tree._nodes["0000000000000"]
# Extract stats
assert index.getNovelWordCount(activeOnly=False) == 43
@@ -1147,7 +1156,7 @@ def testCoreIndex_ItemIndex(mockGUI, fncPath, mockRnd):
project = NWProject()
mockRnd.reset()
buildTestProject(project, fncPath)
project.index.clearIndex()
project.index.clear()
nHandle = C.hTitlePage
cHandle = C.hChapterDoc
@@ -1281,7 +1290,8 @@ def testCoreIndex_ItemIndex(mockGUI, fncPath, mockRnd):
assert nStruct[0][0] == uHandle
# Inject garbage into tree
project.tree._order.append("stuff")
project.tree._items["stuff"] = None # type: ignore
project.tree._nodes["stuff"] = None # type: ignore
nStruct = list(itemIndex.iterNovelStructure())
assert len(nStruct) == 4
assert nStruct[0][0] == nHandle
+1 -6
View File
@@ -176,11 +176,6 @@ def testCoreItem_Setters(mockGUI, mockRnd, fncPath):
item.setCursorPos(1)
assert item.cursorPos == 1
# Initial Count
item.setWordCount(234)
item.saveInitialCount()
assert item.initCount == 234
@pytest.mark.core
def testCoreItem_Methods(mockGUI, mockRnd, fncPath):
@@ -280,7 +275,7 @@ def testCoreItem_Methods(mockGUI, mockRnd, fncPath):
"handle": "000000000000f",
"parent": "000000000000d",
"root": "0000000000008",
"order": "0",
"order": "1",
"type": "FILE",
"class": "NOVEL",
"layout": "DOCUMENT"
+633
View File
@@ -0,0 +1,633 @@
"""
novelWriter Item Model Tester
===============================
This file is a part of novelWriter
Copyright 20182024, Veronica Berglyd Olsen
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful, but
WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
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 pytest
from PyQt5.QtCore import QMimeData, QModelIndex, Qt
from novelwriter.common import decodeMimeHandles
from novelwriter.constants import nwConst
from novelwriter.core.item import NWItem
from novelwriter.core.itemmodel import INV_ROOT, NODE_FLAGS, ProjectNode
from novelwriter.core.project import NWProject
from novelwriter.enum import nwItemLayout, nwItemType
from tests.tools import buildTestProject
@pytest.mark.core
def testCoreItemModel_ProjectNode_Root(mockGUI):
"""Test the project node class for the root."""
project = NWProject()
root = ProjectNode(NWItem(project, INV_ROOT))
# Defaults
assert bool(root) is True
assert root.item.itemHandle == INV_ROOT
assert repr(root) == "<ProjectNode handle=invisibleRoot parent=None row=0 children=0>"
assert root.children == []
assert root.count == 0
# Data
assert root.row() == 0
assert root.childCount() == 0
assert root.parent() is None
assert root.child(0) is None
assert root.allChildren() == []
@pytest.mark.core
def testCoreItemModel_ProjectNode_Children(mockGUI, mockRnd, fncPath):
"""Test the project node class for children."""
project = NWProject()
mockRnd.reset()
buildTestProject(project, fncPath)
root = project.tree.model.root
# Check Root
assert root.childCount() == 4
assert root.count == 9
# Check Children
child0 = root.child(0)
child1 = root.child(1)
child2 = root.child(2)
child3 = root.child(3)
assert child0 is not None
assert child1 is not None
assert child2 is not None
assert child3 is not None
assert child0.item.itemName == "Novel"
assert child1.item.itemName == "Plot"
assert child2.item.itemName == "Characters"
assert child3.item.itemName == "Locations"
assert child0.childCount() == 2
assert child1.childCount() == 0
assert child2.childCount() == 0
assert child3.childCount() == 0
# Check Novel Content
child00 = child0.child(0)
child01 = child0.child(1)
assert child00 is not None
assert child01 is not None
assert child00.item.itemName == "Title Page"
assert child01.item.itemName == "New Folder"
child010 = child01.child(0)
child011 = child01.child(1)
assert child010 is not None
assert child011 is not None
assert child010.item.itemName == "New Chapter"
assert child011.item.itemName == "New Scene"
# Check Relationships
assert child0.parent() is root
assert child1.parent() is root
assert child2.parent() is root
assert child3.parent() is root
assert child01.parent() is child0
assert child010.parent() is child01
assert child011.parent() is child01
# Expand
child0.setExpanded(True)
child1.setExpanded(True)
child2.setExpanded(True)
child3.setExpanded(True)
assert child0.item.isExpanded is True # Only one with children
assert child1.item.isExpanded is False
assert child2.item.isExpanded is False
assert child3.item.isExpanded is False
@pytest.mark.core
def testCoreItemModel_ProjectNode_Modify(mockGUI, mockRnd, fncPath):
"""Test modifying project nodes."""
project = NWProject()
mockRnd.reset()
buildTestProject(project, fncPath)
root = project.tree.model.root
# Novel folder
novel = root.child(0)
assert novel is not None
assert novel.item.itemName == "Novel"
# Chapter folder
folder = novel.child(1)
assert folder is not None
assert folder.item.itemName == "New Folder"
# Append scene
project.tree.create("Scene 1", folder.item.itemHandle, nwItemType.FILE)
scene1 = folder.child(2)
assert scene1 is not None
assert scene1.item.itemName == "Scene 1"
# Insert scene
project.tree.create("Scene 2", folder.item.itemHandle, nwItemType.FILE, pos=1)
scene2 = folder.child(1)
assert scene2 is not None
assert scene2.item.itemName == "Scene 2"
# Scene 1 should now have moved
assert scene1.row() == 3
assert [n.item.itemName for n in folder.children] == [
"New Chapter", "Scene 2", "New Scene", "Scene 1",
]
# Check that defaults have been set
assert scene1.item.itemParent == folder.item.itemHandle
assert scene2.item.itemParent == folder.item.itemHandle
assert scene1.item.itemClass == folder.item.itemClass
assert scene2.item.itemClass == folder.item.itemClass
assert scene1.item.itemLayout == nwItemLayout.DOCUMENT
assert scene2.item.itemLayout == nwItemLayout.DOCUMENT
# Move Scene 2, invalid position is ignored
folder.moveChild(1, -1)
scene2 = folder.child(1)
assert scene2 is not None
assert scene2.item.itemName == "Scene 2"
# Move Scene 2, past end is ignored
folder.moveChild(1, 20)
scene2 = folder.child(1)
assert scene2 is not None
assert scene2.item.itemName == "Scene 2"
# Move Scene 2, last position is ok
folder.moveChild(1, 3)
scene2 = folder.child(3)
assert scene2 is not None
assert scene2.item.itemName == "Scene 2"
assert [n.item.itemName for n in folder.children] == [
"New Chapter", "New Scene", "Scene 1", "Scene 2",
]
# Remove original scene, invalid position
removed = folder.takeChild(-1)
assert removed is None
assert folder.childCount() == 4
# Remove original scene, past end
removed = folder.takeChild(20)
assert removed is None
assert folder.childCount() == 4
# Remove original scene, ok
removed = folder.takeChild(1)
assert removed is not None
assert removed.item.itemName == "New Scene"
assert folder.childCount() == 3
assert [n.item.itemName for n in folder.children] == [
"New Chapter", "Scene 1", "Scene 2",
]
@pytest.mark.core
def testCoreItemModel_ProjectNode_Data(mockGUI, mockRnd, fncPath):
"""Test data access from project nodes."""
project = NWProject()
mockRnd.reset()
buildTestProject(project, fncPath)
root = project.tree.model.root
# Novel folder
novel = root.child(0)
assert novel is not None
assert novel.item.itemName == "Novel"
# Chapter folder
folder = novel.child(1)
assert folder is not None
assert folder.item.itemName == "New Folder"
# Scene document
scene = folder.child(1)
assert scene is not None
assert scene.item.itemName == "New Scene"
# Check Data
assert novel.data(0, Qt.ItemDataRole.DisplayRole) == "Novel"
assert novel.data(1, Qt.ItemDataRole.DisplayRole) == "9"
assert scene.data(0, Qt.ItemDataRole.DisplayRole) == "New Scene"
assert scene.data(1, Qt.ItemDataRole.DisplayRole) == "2"
assert novel.data(2, Qt.ItemDataRole.ToolTipRole) == ""
assert novel.data(3, Qt.ItemDataRole.ToolTipRole) == "New"
assert scene.data(2, Qt.ItemDataRole.ToolTipRole) == "Active"
assert scene.data(3, Qt.ItemDataRole.ToolTipRole) == "New"
# Check Flags
assert novel.flags() == NODE_FLAGS
assert scene.flags() == NODE_FLAGS | Qt.ItemFlag.ItemIsDragEnabled
@pytest.mark.core
def testCoreItemModel_ProjectModel_Interface(mockGUI, mockRnd, fncPath):
"""Test the model interface for the project model."""
project = NWProject()
mockRnd.reset()
buildTestProject(project, fncPath)
model = project.tree.model
# Init
assert isinstance(model.root, ProjectNode)
assert model.root.item.itemHandle == INV_ROOT
# Indices
rootIdx = QModelIndex()
novelIdx = model.index(0, 0, rootIdx)
folderIdx = model.index(1, 0, novelIdx)
sceneIdx = model.index(1, 0, folderIdx)
invalidIdx = model.index(-1, -1)
assert rootIdx.isValid() is False
assert novelIdx.isValid() is True
assert folderIdx.isValid() is True
assert sceneIdx.isValid() is True
assert invalidIdx.isValid() is False
# Columns and Rows
assert model.rowCount(rootIdx) == 4
assert model.columnCount(rootIdx) == 4
assert model.rowCount(novelIdx) == 2
assert model.columnCount(novelIdx) == 4
# Parent of Novel
parent = model.parent(novelIdx)
assert parent.row() == 0
assert parent.column() == 0
assert parent.internalPointer() is model.root
# Parent of Root
parent = model.parent(rootIdx)
assert parent.isValid() is False
# Data and Flags
assert model.data(novelIdx, Qt.ItemDataRole.DisplayRole) == "Novel"
assert model.data(sceneIdx, Qt.ItemDataRole.DisplayRole) == "New Scene"
assert model.data(invalidIdx, Qt.ItemDataRole.DisplayRole) is None
assert model.flags(novelIdx) == NODE_FLAGS
assert model.flags(sceneIdx) == NODE_FLAGS | Qt.ItemFlag.ItemIsDragEnabled
assert model.flags(invalidIdx) == Qt.ItemFlag.NoItemFlags
@pytest.mark.core
def testCoreItemModel_ProjectModel_DragNDrop(mockGUI, mockRnd, fncPath):
"""Test drag and drop for the project model."""
project = NWProject()
mockRnd.reset()
buildTestProject(project, fncPath)
model = project.tree.model
# Nodes
novel = model.root.child(0)
assert novel is not None
folder = novel.child(1)
assert folder is not None
chapter = folder.child(0)
assert chapter is not None
scene = folder.child(1)
assert scene is not None
assert novel.item.itemName == "Novel"
assert folder.item.itemName == "New Folder"
assert chapter.item.itemName == "New Chapter"
assert scene.item.itemName == "New Scene"
# Indices
rootIdx = QModelIndex()
novelIdx = model.index(0, 0, rootIdx)
folderIdx = model.index(1, 0, novelIdx)
chapterIdx = model.index(0, 0, folderIdx)
sceneIdx = model.index(1, 0, folderIdx)
invalidIdx = model.index(-1, -1)
# Only move is allowed
assert model.supportedDragActions() == Qt.DropAction.MoveAction
assert model.supportedDropActions() == Qt.DropAction.MoveAction
# Only handles are dragged and dropped
assert model.mimeTypes() == [nwConst.MIME_HANDLE]
# Get mime data
novelMime = model.mimeData([novelIdx])
assert decodeMimeHandles(novelMime) == [novel.item.itemHandle]
sceneMime = model.mimeData([sceneIdx])
assert decodeMimeHandles(sceneMime) == [scene.item.itemHandle]
sceneChapterMime = model.mimeData([chapterIdx, sceneIdx])
assert decodeMimeHandles(sceneChapterMime) == [
chapter.item.itemHandle, scene.item.itemHandle,
]
multiMime = model.mimeData([novelIdx, folderIdx, sceneIdx, invalidIdx])
assert decodeMimeHandles(multiMime) == [
novel.item.itemHandle, folder.item.itemHandle, scene.item.itemHandle,
]
# Check that drop is possible
invalidMime = QMimeData()
invalidMime.setData("plain/text", b"foobar")
assert model.canDropMimeData(invalidMime, Qt.DropAction.MoveAction, 0, 0, novelIdx) is False
assert model.canDropMimeData(sceneMime, Qt.DropAction.MoveAction, 0, 0, novelIdx) is True
# Drop the scene on the novel folder
assert [n.item.itemName for n in model.root.allChildren()] == [
"Novel", "Title Page", "New Folder", "New Chapter", "New Scene",
"Plot", "Characters", "Locations",
]
assert model.dropMimeData(invalidMime, Qt.DropAction.MoveAction, 0, 0, novelIdx) is False
assert model.dropMimeData(sceneChapterMime, Qt.DropAction.MoveAction, 0, 0, novelIdx) is True
assert [n.item.itemName for n in model.root.allChildren()] == [
"Novel", "New Chapter", "New Scene", "Title Page", "New Folder",
"Plot", "Characters", "Locations",
]
@pytest.mark.core
def testCoreItemModel_ProjectModel_Data(mockGUI, mockRnd, fncPath):
"""Test data access for the project model."""
project = NWProject()
mockRnd.reset()
buildTestProject(project, fncPath)
model = project.tree.model
# Nodes
root = model.root
novel = root.child(0)
assert novel is not None
folder = novel.child(1)
assert folder is not None
chapter = folder.child(0)
assert chapter is not None
scene = folder.child(1)
assert scene is not None
assert novel.item.itemName == "Novel"
assert folder.item.itemName == "New Folder"
assert chapter.item.itemName == "New Chapter"
assert scene.item.itemName == "New Scene"
# Indices
rootIdx = QModelIndex()
novelIdx = model.index(0, 0, rootIdx)
folderIdx = model.index(1, 0, novelIdx)
chapterIdx = model.index(0, 0, folderIdx)
sceneIdx = model.index(1, 0, folderIdx)
invalidIdx = model.index(-1, -1)
# Check Rows
assert model.row(rootIdx) == -1
assert model.row(novelIdx) == 0
assert model.row(folderIdx) == 1
assert model.row(chapterIdx) == 0
assert model.row(sceneIdx) == 1
assert model.row(invalidIdx) == -1
# Check Nodes
assert model.node(rootIdx) is None
assert model.node(novelIdx) is novel
assert model.node(folderIdx) is folder
assert model.node(chapterIdx) is chapter
assert model.node(sceneIdx) is scene
assert model.node(invalidIdx) is None
nodes = model.nodes([novelIdx, folderIdx, chapterIdx, sceneIdx])
assert nodes[0] is novel
assert nodes[1] is folder
assert nodes[2] is chapter
assert nodes[3] is scene
# Index from Handle
assert model.indexFromHandle(None).isValid() is False
assert model.node(model.indexFromHandle(novel.item.itemHandle)) is novel
assert model.node(model.indexFromHandle(folder.item.itemHandle)) is folder
assert model.node(model.indexFromHandle(chapter.item.itemHandle)) is chapter
assert model.node(model.indexFromHandle(scene.item.itemHandle)) is scene
# Index from Node
assert model.indexFromHandle(None).isValid() is False
assert model.node(model.indexFromNode(novel)) is novel
assert model.node(model.indexFromNode(folder)) is folder
assert model.node(model.indexFromNode(chapter)) is chapter
assert model.node(model.indexFromNode(scene)) is scene
@pytest.mark.core
def testCoreItemModel_ProjectModel_Edit(qtbot, mockGUI, mockRnd, fncPath):
"""Test editing the project model."""
project = NWProject()
mockRnd.reset()
buildTestProject(project, fncPath)
model = project.tree.model
# Nodes
root = model.root
novel = root.child(0)
assert novel is not None
title = novel.child(0)
assert title is not None
folder = novel.child(1)
assert folder is not None
chapter = folder.child(0)
assert chapter is not None
scene = folder.child(1)
assert scene is not None
assert novel.item.itemName == "Novel"
assert title.item.itemName == "Title Page"
assert folder.item.itemName == "New Folder"
assert chapter.item.itemName == "New Chapter"
assert scene.item.itemName == "New Scene"
# Indices
novelIdx = model.indexFromNode(novel)
titleIdx = model.indexFromNode(title)
folderIdx = model.indexFromNode(folder)
chapterIdx = model.indexFromNode(chapter)
sceneIdx = model.indexFromNode(scene)
invalidIdx = model.index(-1, -1)
# Initial Order
assert [n.item.itemName for n in model.root.allChildren()] == [
"Novel", "Title Page", "New Folder", "New Chapter", "New Scene",
"Plot", "Characters", "Locations",
]
# Remove Child, invalid index
assert model.removeChild(novelIdx, -1) is None
assert model.removeChild(novelIdx, 99) is None
# Remove Child
with qtbot.waitSignal(model.rowsAboutToBeRemoved) as signal:
child = model.removeChild(novelIdx, titleIdx.row())
assert signal.args[0].internalPointer().item.itemName == "Novel"
assert signal.args[1] == 0
assert signal.args[2] == 0
assert child is not None
assert child.item.itemName == "Title Page"
assert [n.item.itemName for n in model.root.allChildren()] == [
"Novel", "New Folder", "New Chapter", "New Scene",
"Plot", "Characters", "Locations",
]
titleIdx = model.indexFromNode(title)
folderIdx = model.indexFromNode(folder)
chapterIdx = model.indexFromNode(chapter)
sceneIdx = model.indexFromNode(scene)
# Insert Child
with qtbot.waitSignal(model.rowsAboutToBeInserted) as signal:
model.insertChild(child, novelIdx, 1)
assert signal.args[0].internalPointer().item.itemName == "Novel"
assert signal.args[1] == 1
assert signal.args[2] == 1
assert [n.item.itemName for n in model.root.allChildren()] == [
"Novel", "New Folder", "New Chapter", "New Scene", "Title Page",
"Plot", "Characters", "Locations",
]
titleIdx = model.indexFromNode(title)
folderIdx = model.indexFromNode(folder)
chapterIdx = model.indexFromNode(chapter)
sceneIdx = model.indexFromNode(scene)
# Move it back with internal move
with qtbot.waitSignal(model.rowsAboutToBeMoved) as signal:
model.internalMove(titleIdx, -1)
assert signal.args[0].internalPointer().item.itemName == "Novel"
assert signal.args[1] == 1
assert signal.args[2] == 1
assert signal.args[3].internalPointer().item.itemName == "Novel"
assert signal.args[4] == 0
assert [n.item.itemName for n in model.root.allChildren()] == [
"Novel", "Title Page", "New Folder", "New Chapter", "New Scene",
"Plot", "Characters", "Locations",
]
titleIdx = model.indexFromNode(title)
folderIdx = model.indexFromNode(folder)
chapterIdx = model.indexFromNode(chapter)
sceneIdx = model.indexFromNode(scene)
# Move Multiple, with parent
# Chapter and scene selection is flipped, but they should be deselected
# because folder is also selected, so their order should not change
model.multiMove([folderIdx, sceneIdx, chapterIdx, invalidIdx], novelIdx, 0)
assert [n.item.itemName for n in model.root.allChildren()] == [
"Novel", "New Folder", "New Chapter", "New Scene", "Title Page",
"Plot", "Characters", "Locations",
]
assert folder.parent() is novel
assert chapter.parent() is folder
assert scene.parent() is folder
titleIdx = model.indexFromNode(title)
folderIdx = model.indexFromNode(folder)
chapterIdx = model.indexFromNode(chapter)
sceneIdx = model.indexFromNode(scene)
# Move Multiple, siblings, altered order
# Chapter and scene selection is flipped, and they should now be reordered,
# and no longer in the folder
model.multiMove([sceneIdx, chapterIdx, invalidIdx], novelIdx, 0)
assert [n.item.itemName for n in model.root.allChildren()] == [
"Novel", "New Scene", "New Chapter", "New Folder", "Title Page",
"Plot", "Characters", "Locations",
]
assert folder.parent() is novel
assert chapter.parent() is novel
assert scene.parent() is novel
@pytest.mark.core
def testCoreItemModel_ProjectModel_Other(qtbot, mockGUI, mockRnd, fncPath):
"""Test other methods of the project model."""
project = NWProject()
mockRnd.reset()
buildTestProject(project, fncPath)
model = project.tree.model
# Nodes
root = model.root
novel = root.child(0)
assert novel is not None
title = novel.child(0)
assert title is not None
folder = novel.child(1)
assert folder is not None
chapter = folder.child(0)
assert chapter is not None
scene = folder.child(1)
assert scene is not None
trash = project.tree.trash
assert trash is not None
assert novel.item.itemName == "Novel"
assert title.item.itemName == "Title Page"
assert folder.item.itemName == "New Folder"
assert chapter.item.itemName == "New Chapter"
assert scene.item.itemName == "New Scene"
assert [n.item.itemName for n in model.root.allChildren()] == [
"Novel", "Title Page", "New Folder", "New Chapter", "New Scene",
"Plot", "Characters", "Locations", "Trash",
]
# Indices
chapterIdx = model.indexFromNode(chapter)
sceneIdx = model.indexFromNode(scene)
trashIdx = model.indexFromNode(trash)
# Expanded
assert model.allExpanded() == []
novel.item.setExpanded(True)
folder.item.setExpanded(True)
assert [model.node(i) for i in model.allExpanded()] == [novel, folder]
# Check Trash
assert model.trashSelection([chapterIdx, sceneIdx]) is False
model.multiMove([chapterIdx, sceneIdx], trashIdx)
assert [n.item.itemName for n in model.root.allChildren()] == [
"Novel", "Title Page", "New Folder",
"Plot", "Characters", "Locations", "Trash", "New Chapter", "New Scene",
]
chapterIdx = model.indexFromNode(chapter)
sceneIdx = model.indexFromNode(scene)
assert model.trashSelection([chapterIdx, sceneIdx]) is True
# Clear
model.clear()
assert [n.item.itemName for n in model.root.allChildren()] == []
+1 -93
View File
@@ -29,12 +29,8 @@ from PyQt5.QtWidgets import QMessageBox
from novelwriter import CONFIG, SHARED
from novelwriter.constants import nwFiles
from novelwriter.core.index import NWIndex
from novelwriter.core.item import NWItem
from novelwriter.core.options import OptionState
from novelwriter.core.project import NWProject, NWProjectState
from novelwriter.core.projectxml import ProjectXMLReader, ProjectXMLWriter, XMLReadState
from novelwriter.core.tree import NWTree
from novelwriter.enum import nwItemClass
from tests.mocked import causeOSError
@@ -284,7 +280,7 @@ def testCoreProject_Open(monkeypatch, caplog, mockGUI, fncPath, mockRnd):
# Fail checking items should still pass
with monkeypatch.context() as mp:
mp.setattr("novelwriter.core.tree.NWTree.updateItemData", lambda *a: False)
mp.setattr("novelwriter.core.tree.NWTree.checkConsistency", lambda *a: (1, 0))
assert project.openProject(fncPath, clearLock=True) is True
# Trigger an index rebuild
@@ -326,70 +322,6 @@ def testCoreProject_Save(monkeypatch, mockGUI, mockRnd, fncPath):
project.closeProject()
@pytest.mark.core
def testCoreProject_AccessItems(mockGUI, fncPath, mockRnd):
"""Test helper functions for the project folder."""
project = NWProject()
buildTestProject(project, fncPath)
# Storage Objects
assert isinstance(project.index, NWIndex)
assert isinstance(project.tree, NWTree)
assert isinstance(project.options, OptionState)
# Move Novel ROOT to after its files
oldOrder = [
C.hNovelRoot,
C.hPlotRoot,
C.hCharRoot,
C.hWorldRoot,
C.hTitlePage,
C.hChapterDir,
C.hChapterDoc,
C.hSceneDoc,
]
newOrder = [
C.hTitlePage,
C.hChapterDoc,
C.hSceneDoc,
C.hChapterDir,
C.hNovelRoot,
C.hPlotRoot,
C.hCharRoot,
C.hWorldRoot,
]
assert project.tree.handles() == oldOrder
project.setTreeOrder(newOrder)
assert project.tree.handles() == newOrder
# Add a non-existing item
project.tree._order.append(C.hInvalid)
# Add an item with a non-existent parent
nHandle = project.newFile("Test File", C.hChapterDir)
nItem = project.tree[nHandle]
assert isinstance(nItem, NWItem)
nItem.setParent("cba9876543210")
assert nItem.itemParent == "cba9876543210"
retOrder = []
for tItem in project.iterProjectItems():
retOrder.append(tItem.itemHandle)
assert retOrder == [
C.hNovelRoot,
C.hPlotRoot,
C.hCharRoot,
C.hWorldRoot,
nHandle,
C.hTitlePage,
C.hChapterDir,
C.hChapterDoc,
C.hSceneDoc,
]
assert nItem.itemParent is None
@pytest.mark.core
def testCoreProject_Methods(monkeypatch, mockGUI, fncPath, mockRnd):
"""Test other project class methods and functions."""
@@ -411,13 +343,6 @@ def testCoreProject_Methods(monkeypatch, mockGUI, fncPath, mockRnd):
mp.setattr("novelwriter.core.project.time", lambda: 1600005600)
assert project.currentEditTime == 6834
# Trash folder
# Should create on first call, and just returned on later calls
hTrash = "0000000000010"
assert project.tree[hTrash] is None
assert project.trashFolder() == hTrash
assert project.trashFolder() == hTrash
# Spell check
project.setProjectChanged(False)
project.data.setSpellCheck(True)
@@ -480,23 +405,6 @@ def testCoreProject_Methods(monkeypatch, mockGUI, fncPath, mockRnd):
assert project.data.autoReplace == {"A": "B", "C": "D"}
assert project.projChanged
# Change project tree order
oldOrder = [
"0000000000008", "0000000000009", "000000000000a",
"000000000000b", "000000000000c", "000000000000d",
"000000000000e", "000000000000f", "0000000000010",
]
newOrder = [
"000000000000b", "000000000000c", "000000000000d",
"0000000000008", "0000000000009", "000000000000a",
"000000000000e", "000000000000f",
]
assert project.tree.handles() == oldOrder
project.setTreeOrder(newOrder)
assert project.tree.handles() == newOrder
project.setTreeOrder(oldOrder)
assert project.tree.handles() == oldOrder
@pytest.mark.core
def testCoreProject_Backup(monkeypatch, mockGUI, fncPath, tstPaths):
+272 -392
View File
@@ -22,279 +22,311 @@ from __future__ import annotations
import random
from copy import deepcopy
from pathlib import Path
import pytest
from novelwriter.common import isHandle
from novelwriter.constants import nwFiles
from novelwriter.core.item import NWItem
from novelwriter.core.project import NWProject
from novelwriter.core.tree import NWTree
from novelwriter.enum import nwItemClass, nwItemLayout, nwItemType
from novelwriter.enum import nwItemClass, nwItemType
from tests.mocked import causeOSError
from tests.tools import C, buildTestProject
@pytest.fixture(scope="function")
def mockItems(mockGUI, mockRnd):
def mockItems(mockGUI, mockRnd, fncPath):
"""Create a list of mock items."""
project = NWProject()
itemA = NWItem(project, "a000000000001")
itemA._name = "Novel"
itemA._parent = None
itemA._type = nwItemType.ROOT
itemA._class = nwItemClass.NOVEL
itemA._expanded = True
itemB = NWItem(project, "b000000000001")
itemB._name = "Act One"
itemB._parent = "a000000000001"
itemB._type = nwItemType.FOLDER
itemB._class = nwItemClass.NOVEL
itemB._expanded = True
itemC = NWItem(project, "c000000000001")
itemC._name = "Chapter One"
itemC._parent = "b000000000001"
itemC._type = nwItemType.FILE
itemC._class = nwItemClass.NOVEL
itemC._layout = nwItemLayout.DOCUMENT
itemC._charCount = 300
itemC._wordCount = 50
itemC._paraCount = 2
itemD = NWItem(project, "c000000000002")
itemD._name = "Scene One"
itemD._parent = "b000000000001"
itemD._type = nwItemType.FILE
itemD._class = nwItemClass.NOVEL
itemD._layout = nwItemLayout.DOCUMENT
itemD._charCount = 3000
itemD._wordCount = 500
itemD._paraCount = 20
itemE = NWItem(project, "a000000000002")
itemE._name = "Outtakes"
itemE._parent = None
itemE._type = nwItemType.ROOT
itemE._class = nwItemClass.ARCHIVE
itemE._expanded = False
itemF = NWItem(project, "a000000000003")
itemF._name = "Trash"
itemF._parent = None
itemF._type = nwItemType.ROOT
itemF._class = nwItemClass.TRASH
itemF._expanded = False
itemG = NWItem(project, "a000000000004")
itemG._name = "Characters"
itemG._parent = None
itemG._type = nwItemType.ROOT
itemG._class = nwItemClass.CHARACTER
itemG._expanded = True
itemH = NWItem(project, "b000000000002")
itemH._name = "Jane Doe"
itemH._parent = "a000000000004"
itemH._type = nwItemType.FILE
itemH._class = nwItemClass.CHARACTER
itemH._layout = nwItemLayout.NOTE
itemH._charCount = 2000
itemH._wordCount = 400
itemH._paraCount = 16
return [itemA, itemB, itemC, itemD, itemE, itemF, itemG, itemH]
mockRnd.reset()
buildTestProject(project, fncPath)
return project.tree.pack()
@pytest.mark.core
def testCoreTree_BuildTree(mockGUI, mockItems):
"""Test building a project tree from a list of items."""
def testCoreTree_Populate(monkeypatch, mockGUI, mockItems):
"""Test populating the project tree."""
project = NWProject()
tree = NWTree(project)
# Check that tree is empty (calls NWTree.__bool__)
# Check that tree is empty
assert bool(tree) is False
assert len(tree) == 0
# Check for archive and trash folders
assert tree.trashRoot is None
aHandles = []
for nwItem in mockItems:
aHandles.append(nwItem.itemHandle)
assert tree.append(nwItem) is True
assert tree.updateItemData(nwItem.itemHandle) is True
assert tree._changed is True
# Check that tree is not empty (calls __bool__)
# Trash should be added on request
assert tree.trash is not None
assert tree.nodes[tree.trash.item.itemHandle].item.itemName == "Trash"
assert bool(tree) is True
assert len(tree) == 1
tree.clear()
# Check the number of elements (calls __len__)
assert len(tree) == len(mockItems)
# Load Items
tree.unpack(mockItems)
assert len(tree) == 9
assert tree.trash is not None
assert [n.item.itemName for n in tree.model.root.allChildren()] == [
"Novel", "Title Page", "New Folder", "New Chapter", "New Scene",
"Plot", "Characters", "Locations", "Trash",
]
# Check that we have the correct handles
assert tree.handles() == aHandles
# Pack the data again
assert [n["name"] for n in tree.pack()] == [
"Novel", "Title Page", "New Folder", "New Chapter", "New Scene",
"Plot", "Characters", "Locations", "Trash",
]
# Check by iterator (calls __iter__, __next__ and __getitem__)
for item, handle in zip(tree, aHandles):
assert item.itemHandle == handle
# Inject a node in the map, but not in the tree (inconsistent tree)
# This should be ignored
tree._nodes["123456789abc"] = tree.model.root
assert [n["name"] for n in tree.pack()] == [
"Novel", "Title Page", "New Folder", "New Chapter", "New Scene",
"Plot", "Characters", "Locations", "Trash",
]
# Trash Folder
# ============
# Clear, and populate in reverse order
tree.clear()
assert bool(tree) is False
assert len(tree) == 0
tree.unpack(list(reversed(mockItems)))
assert [n.item.itemName for n in tree.model.root.allChildren()] == [
"Locations", "Characters", "Plot", "Novel", "New Folder",
"New Scene", "New Chapter", "Title Page", "Trash",
]
# Check that we have the correct archive and trash folders
assert tree.trashRoot == "a000000000003"
assert tree.findRoot(nwItemClass.ARCHIVE) == "a000000000002"
assert tree.isTrash("a000000000003") is True
# Clear, and populate with one item being its own parent
tree.clear()
assert bool(tree) is False
assert len(tree) == 0
modItems = deepcopy(mockItems)
assert modItems[1]["name"] == "Title Page"
modItems[1]["itemAttr"]["parent"] = modItems[1]["itemAttr"]["handle"]
tree.unpack(mockItems)
assert len(tree) == 9
assert [n.item.itemName for n in tree.model.root.allChildren()] == [
"Novel", "Title Page", "New Folder", "New Chapter", "New Scene",
"Plot", "Characters", "Locations", "Trash",
]
# Check that we have the root classes
assert tree.rootClasses() == {
nwItemClass.NOVEL, nwItemClass.CHARACTER, nwItemClass.ARCHIVE, nwItemClass.TRASH
}
# Check the isTrash function
assert tree.isTrash("0000000000000") is True # Doesn't exist
assert tree.isTrash("a000000000003") is True # This the trash folder
tree["a000000000003"].setClass(nwItemClass.NO_CLASS) # type: ignore
assert tree.isTrash("a000000000003") is True # This is still trash
tree["a000000000003"].setClass(nwItemClass.TRASH) # type: ignore
assert tree.isTrash("b000000000002") is False # This is not trash
value = tree["b000000000002"].itemParent # type: ignore
tree["b000000000002"].setParent("a000000000003") # type: ignore
assert tree.isTrash("b000000000002") is True # This is in trash
tree["b000000000002"].setParent(value) # type: ignore
value = tree["b000000000002"].itemRoot # type: ignore
tree["b000000000002"].setRoot("a000000000003") # type: ignore
assert tree.isTrash("b000000000002") is True # This is in trash
tree["b000000000002"].setRoot(value) # type: ignore
# Try to add another trash folder
itemT = NWItem(project, "1111111111111")
itemT._name = "Trash"
itemT._type = nwItemType.ROOT
itemT._class = nwItemClass.TRASH
itemT._expanded = False
assert tree.append(itemT) is False
assert len(tree) == len(mockItems)
# Create or Add Items
# ===================
# Create a new item, but with invalid parent
assert tree.create("New File", "blabla", nwItemType.FILE, nwItemClass.NO_CLASS) is None
# Create a new, valid item
nHandle = tree.create("New File", "b000000000001", nwItemType.FILE, nwItemClass.NO_CLASS)
assert isHandle(nHandle)
assert nHandle == "0000000000000"
# The new item should be the last item in the tree
handles = tree.handles()
assert handles[-1] == nHandle
# Retrieve the item
itemT = tree[nHandle]
assert isinstance(itemT, NWItem)
assert len(tree) == len(mockItems) + 1
# We should not be allowed to add the item again
assert tree.append(itemT) is False
assert len(tree) == len(mockItems) + 1
# Create an invalid item to add, which will be rejected
itemU = NWItem.duplicate(itemT, "blabla")
assert tree.append(itemU) is False
assert len(tree) == len(mockItems) + 1
# Create a new root, but with a parent set anyway (the parent should be ignored)
zHandle = tree.create("Custom", "a000000000001", nwItemType.ROOT, nwItemClass.CUSTOM)
assert isinstance(zHandle, str)
itemZ = tree[zHandle]
assert isinstance(itemZ, NWItem)
assert itemZ.itemParent is None
del tree[zHandle]
# Duplicate Items
# ===============
# Duplicate a non-existing item
assert tree.duplicate("blabla") is None
# Duplicate the new item
itemV = tree.duplicate(nHandle)
assert isinstance(itemV, NWItem)
assert len(tree) == len(mockItems) + 2
dHandle = itemV.itemHandle
assert dHandle == "0000000000002"
# Delete Items
# ============
# Delete a non-existing item
del tree["stuff"]
assert len(tree) == len(mockItems) + 2
# Delete the last items
del tree[nHandle]
del tree[dHandle]
assert len(tree) == len(mockItems)
assert nHandle not in tree
# Delete the Novel, Archive and Trash folders
del tree["a000000000001"]
assert len(tree) == len(mockItems) - 1
assert "a000000000001" not in tree
del tree["a000000000002"]
assert len(tree) == len(mockItems) - 2
assert "a000000000002" not in tree
del tree["a000000000003"]
assert len(tree) == len(mockItems) - 3
assert "a000000000003" not in tree
assert tree.trashRoot is None
# Clear and populate reversed with max depth limit very low
with monkeypatch.context() as mp:
mp.setattr("novelwriter.core.tree.MAX_DEPTH", 1)
tree.clear()
assert bool(tree) is False
assert len(tree) == 0
tree.unpack(list(reversed(mockItems)))
assert [n.item.itemName for n in tree.model.root.allChildren()] == [
"Locations", "Characters", "Plot", "Novel", "Trash",
]
@pytest.mark.core
def testCoreTree_PackUnpack(mockGUI, mockItems):
"""Test packing and unpacking data."""
def testCoreTree_ManipulateTree(mockGUI, mockItems):
"""Check create, add, remove and duplicate items."""
project = NWProject()
tree = NWTree(project)
tree.unpack(mockItems)
assert len(tree) == 9
assert [n.item.itemName for n in tree.model.root.allChildren()] == [
"Novel", "Title Page", "New Folder", "New Chapter", "New Scene",
"Plot", "Characters", "Locations", "Trash",
]
aHandles = []
for nwItem in mockItems:
aHandles.append(nwItem.itemHandle)
tree.append(nwItem)
tree.updateItemData(nwItem.itemHandle)
# Create Root
oHandle = tree.create("Objects", None, nwItemType.ROOT, nwItemClass.OBJECT, pos=4)
assert oHandle is not None
assert oHandle in tree
assert len(tree) == len(mockItems)
# Create Folder
fHandle = tree.create("Foo", oHandle, nwItemType.FOLDER, pos=0)
assert fHandle is not None
assert fHandle in tree
# Pack
packed = tree.pack()
for i, nwItem in enumerate(mockItems):
assert packed[i]["itemAttr"]["handle"] == nwItem.itemHandle
# Create File
bHandle = tree.create("Bar", fHandle, nwItemType.FILE, pos=0)
assert bHandle is not None
assert bHandle in tree
# Unpack
tree.clear()
assert len(tree) == 0
assert tree.handles() == []
tree.unpack(packed)
assert tree.handles() == aHandles
# Check Tree
assert [n.item.itemName for n in tree.model.root.allChildren()] == [
"Novel", "Title Page", "New Folder", "New Chapter", "New Scene",
"Plot", "Characters", "Locations", "Objects", "Foo", "Bar", "Trash",
]
# Cannot add a folder or file with no parent
assert tree.create("Foo", None, nwItemType.FOLDER, pos=0) is None
assert tree.create("Bar", None, nwItemType.FILE, pos=0) is None
assert [n.item.itemName for n in tree.model.root.allChildren()] == [
"Novel", "Title Page", "New Folder", "New Chapter", "New Scene",
"Plot", "Characters", "Locations", "Objects", "Foo", "Bar", "Trash",
]
# Duplicate Bar -> Baz
baz = tree.duplicate(bHandle, fHandle, True)
assert baz is not None
baz.setName("Baz")
zHandle = baz.itemHandle
assert [n.item.itemName for n in tree.model.root.allChildren()] == [
"Novel", "Title Page", "New Folder", "New Chapter", "New Scene",
"Plot", "Characters", "Locations", "Objects", "Foo", "Bar", "Baz", "Trash",
]
# Duplicate non-existent
assert tree.duplicate("bob", fHandle, True) is None
assert [n.item.itemName for n in tree.model.root.allChildren()] == [
"Novel", "Title Page", "New Folder", "New Chapter", "New Scene",
"Plot", "Characters", "Locations", "Objects", "Foo", "Bar", "Baz", "Trash",
]
# Remove Baz
assert tree.remove(zHandle) is True
assert [n.item.itemName for n in tree.model.root.allChildren()] == [
"Novel", "Title Page", "New Folder", "New Chapter", "New Scene",
"Plot", "Characters", "Locations", "Objects", "Foo", "Bar", "Trash",
]
assert len(tree._items) == len(tree.model.root.allChildren())
assert len(tree._items) == len(tree._nodes)
# Remove non-existing
assert tree.remove("bob") is False
# Add item with non-existing parent
item = NWItem(project, tree._makeHandle())
item.setParent(tree._makeHandle())
assert tree.add(item) is False
assert [n.item.itemName for n in tree.model.root.allChildren()] == [
"Novel", "Title Page", "New Folder", "New Chapter", "New Scene",
"Plot", "Characters", "Locations", "Objects", "Foo", "Bar", "Trash",
]
# Add item with non parent, that isn't a root
item = NWItem(project, tree._makeHandle())
item.setType(nwItemType.FOLDER)
assert tree.add(item) is False
assert [n.item.itemName for n in tree.model.root.allChildren()] == [
"Novel", "Title Page", "New Folder", "New Chapter", "New Scene",
"Plot", "Characters", "Locations", "Objects", "Foo", "Bar", "Trash",
]
@pytest.mark.core
def testCoreTree_CheckConsistency(caplog: pytest.LogCaptureFixture, mockGUI, fncPath, mockRnd):
"""Check the project consistency."""
def testCoreTree_ItemMethods(monkeypatch, mockGUI, mockItems):
"""Check the item methods of the tree."""
project = NWProject()
tree = NWTree(project)
tree.unpack(mockItems)
assert len(tree) == 9
assert [n.item.itemName for n in tree.model.root.allChildren()] == [
"Novel", "Title Page", "New Folder", "New Chapter", "New Scene",
"Plot", "Characters", "Locations", "Trash",
]
# Check Type
assert tree.checkType(C.hNovelRoot, nwItemType.ROOT) is True
assert tree.checkType(C.hNovelRoot, nwItemType.FOLDER) is False
assert tree.checkType(C.hNovelRoot, nwItemType.FILE) is False
assert tree.checkType(C.hChapterDir, nwItemType.ROOT) is False
assert tree.checkType(C.hChapterDir, nwItemType.FOLDER) is True
assert tree.checkType(C.hChapterDir, nwItemType.FILE) is False
assert tree.checkType(C.hChapterDoc, nwItemType.ROOT) is False
assert tree.checkType(C.hChapterDoc, nwItemType.FOLDER) is False
assert tree.checkType(C.hChapterDoc, nwItemType.FILE) is True
assert tree.checkType(C.hInvalid, nwItemType.ROOT) is False
assert tree.checkType(C.hInvalid, nwItemType.FOLDER) is False
assert tree.checkType(C.hInvalid, nwItemType.FILE) is False
# Item Path
assert tree.itemPath(C.hSceneDoc, asName=True) == ["New Scene", "New Folder", "Novel"]
with monkeypatch.context() as mp:
mp.setattr("novelwriter.core.tree.MAX_DEPTH", 1)
assert tree.itemPath(C.hSceneDoc, asName=True) == ["New Scene"]
# Sub Tree
assert tree.subTree(C.hInvalid) == []
assert tree.subTree(C.hNovelRoot) == [
C.hTitlePage, C.hChapterDir, C.hChapterDoc, C.hSceneDoc,
]
# Root Classes
classes = tree.rootClasses()
assert len(classes) == 5
assert nwItemClass.NOVEL in classes
assert nwItemClass.PLOT in classes
assert nwItemClass.CHARACTER in classes
assert nwItemClass.WORLD in classes
assert nwItemClass.TIMELINE not in classes
assert nwItemClass.OBJECT not in classes
assert nwItemClass.ENTITY not in classes
assert nwItemClass.CUSTOM not in classes
assert nwItemClass.ARCHIVE not in classes
assert nwItemClass.TEMPLATE not in classes
assert nwItemClass.TRASH in classes
# Iter Roots
assert list(tree.iterRoots(nwItemClass.NOVEL)) == [(C.hNovelRoot, tree[C.hNovelRoot])]
assert list(tree.iterRoots(nwItemClass.PLOT)) == [(C.hPlotRoot, tree[C.hPlotRoot])]
assert list(tree.iterRoots(nwItemClass.CHARACTER)) == [(C.hCharRoot, tree[C.hCharRoot])]
assert list(tree.iterRoots(nwItemClass.WORLD)) == [(C.hWorldRoot, tree[C.hWorldRoot])]
assert list(tree.iterRoots(nwItemClass.OBJECT)) == []
# Find Root
assert tree.findRoot(nwItemClass.NOVEL) == C.hNovelRoot
assert tree.findRoot(nwItemClass.PLOT) == C.hPlotRoot
assert tree.findRoot(nwItemClass.CHARACTER) == C.hCharRoot
assert tree.findRoot(nwItemClass.WORLD) == C.hWorldRoot
assert tree.findRoot(nwItemClass.OBJECT) is None
@pytest.mark.core
def testCoreTree_OtherMethods(qtbot, monkeypatch, mockGUI, fncPath, mockRnd):
"""Check other methods in the tree."""
project = NWProject()
buildTestProject(project, fncPath)
tree = project.tree
trash = tree.trash
assert len(tree) == 9
assert [n.item.itemName for n in tree.model.root.allChildren()] == [
"Novel", "Title Page", "New Folder", "New Chapter", "New Scene",
"Plot", "Characters", "Locations", "Trash",
]
# Refresh All
assert tree.sumWords() == (9, 0)
assert tree.model.root.count == 9
for node in tree.nodes.values():
if node.item.isFileType():
node.item.setWordCount(5)
with qtbot.waitSignal(tree._model.layoutChanged):
tree.refreshAllItems()
assert tree.model.root.count == 15
project.index.rebuild()
tree.refreshAllItems()
assert tree.model.root.count == 9
# Trash can't be created
assert trash is not None
assert tree._getTrashNode() is trash
tree._trash = None
assert tree._getTrashNode() is trash
tree.remove(trash.item.itemHandle)
with monkeypatch.context() as mp:
mp.setattr("novelwriter.core.tree.NWTree.create", lambda *a, **k: None)
assert tree._getTrashNode() is None
@pytest.mark.core
def testCoreTree_CheckConsistency(caplog, mockGUI, fncPath, mockRnd):
"""Check the project tree's consistency."""
project = NWProject()
buildTestProject(project, fncPath)
@@ -303,17 +335,6 @@ def testCoreTree_CheckConsistency(caplog: pytest.LogCaptureFixture, mockGUI, fnc
assert project.tree.checkConsistency("Recovered") == (0, 0)
assert all(m.endswith("OK") for m in caplog.messages)
# Give the scene file an unknown parent
caplog.clear()
project.tree[C.hSceneDoc].setParent(C.hInvalid) # type: ignore
assert project.tree.checkConsistency("Recovered") == (1, 1)
assert f"'{C.hSceneDoc}' ... ERROR" in caplog.text
# The scene file should have been added back to its home
itemS = project.tree[C.hSceneDoc]
assert isinstance(itemS, NWItem)
assert itemS.itemParent == C.hChapterDir
# Create a new file with no meta data, and let the function handle it as orphaned
xHandle = "0123456789abc"
contentPath = project.storage.contentPath
@@ -339,7 +360,7 @@ def testCoreTree_CheckConsistency(caplog: pytest.LogCaptureFixture, mockGUI, fnc
project.storage.getDocument(xHandle).writeDocument("### Stuff") # This adds meta data
# Remove the item in the project, and re-run the consistency check
del project.tree[xHandle]
project.tree.remove(xHandle)
assert project.tree.checkConsistency("Recovered") == (1, 1)
assert xHandle in project.tree
itemX = project.tree[xHandle]
@@ -359,85 +380,6 @@ def testCoreTree_CheckConsistency(caplog: pytest.LogCaptureFixture, mockGUI, fnc
assert project.tree[nHandle].itemName == "Recovered" # type: ignore
@pytest.mark.core
def testCoreTree_Methods(monkeypatch, mockGUI, mockItems):
"""Test various class methods."""
project = NWProject()
tree = NWTree(project)
for nwItem in mockItems:
tree.append(nwItem)
tree.updateItemData(nwItem.itemHandle)
assert len(tree) == len(mockItems)
# Update item data, nonsense handle
assert tree.updateItemData("stuff") is False
# Update item data, invalid item parent
corrParent = tree["b000000000001"].itemParent # type: ignore
tree["b000000000001"].setParent("0000000000000") # type: ignore
assert tree.updateItemData("b000000000001") is False
# Update item data, valid item parent
tree["b000000000001"].setParent(corrParent) # type: ignore
assert tree.updateItemData("b000000000001") is True
# Update item data, root is unreachable
with monkeypatch.context() as mp:
mp.setattr("novelwriter.core.tree.MAX_DEPTH", 0)
with pytest.raises(RecursionError):
tree.updateItemData("b000000000001")
# Check type
assert tree.checkType("blabla", nwItemType.FILE) is False
assert tree.checkType("b000000000001", nwItemType.FILE) is False
assert tree.checkType("c000000000001", nwItemType.FILE) is True
# Root item lookup
assert tree.findRoot(nwItemClass.WORLD) is None
assert tree.findRoot(nwItemClass.NOVEL) == "a000000000001"
assert tree.findRoot(nwItemClass.CHARACTER) == "a000000000004"
# Iter roots
roots = list(tree.iterRoots(None))
assert roots[0][0] == "a000000000001"
assert roots[1][0] == "a000000000002"
assert roots[2][0] == "a000000000003"
assert roots[3][0] == "a000000000004"
# Add a fake item to root and check that it can handle it
tree._roots["0000000000000"] = NWItem(project, "0000000000000")
assert tree.findRoot(nwItemClass.WORLD) is None
del tree._roots["0000000000000"]
# Get item path
assert tree.getItemPath("stuff") == []
assert tree.getItemPath("c000000000001") == [
"c000000000001", "b000000000001", "a000000000001"
]
assert tree.getItemPath("c000000000001", asName=True) == [
"Chapter One", "Act One", "Novel"
]
# Cause recursion error
with monkeypatch.context() as mp:
mp.setattr("novelwriter.core.tree.MAX_DEPTH", 0)
with pytest.raises(RecursionError):
tree.getItemPath("c000000000001")
# Break the folder parent handle
tree["b000000000001"]._parent = "stuff" # type: ignore
assert tree.getItemPath("c000000000001") == [
"c000000000001", "b000000000001"
]
tree["b000000000001"]._parent = "a000000000001" # type: ignore
assert tree.getItemPath("c000000000001") == [
"c000000000001", "b000000000001", "a000000000001"
]
@pytest.mark.core
def testCoreTree_MakeHandles(mockGUI):
"""Test generating item handles."""
@@ -450,84 +392,27 @@ def testCoreTree_MakeHandles(mockGUI):
random.seed(42)
tHandle = tree._makeHandle()
assert tHandle == handles[0]
tree._tree[handles[0]] = None # type: ignore
tree._items[handles[0]] = None # type: ignore
# Add the next in line to the project to force duplicate
tree._tree[handles[1]] = None # type: ignore
tree._items[handles[1]] = None # type: ignore
tHandle = tree._makeHandle()
assert tHandle == handles[2]
tree._tree[handles[2]] = None # type: ignore
tree._items[handles[2]] = None # type: ignore
# Reset the seed to force collissions, which should still end up
# Reset the seed to force collisions, which should still end up
# returning the next handle in the sequence
random.seed(42)
tHandle = tree._makeHandle()
assert tHandle == handles[3]
@pytest.mark.core
def testCoreTree_Stats(mockGUI, mockItems):
"""Test project stats methods."""
project = NWProject()
tree = NWTree(project)
for nwItem in mockItems:
tree.append(nwItem)
assert len(tree) == len(mockItems)
tree._order.append("stuff")
# Count Words
novelWords, noteWords = tree.sumWords()
assert novelWords == 550
assert noteWords == 400
@pytest.mark.core
def testCoreTree_Reorder(caplog, mockGUI, mockItems):
"""Test changing tree order."""
project = NWProject()
tree = NWTree(project)
aHandle = []
for nwItem in mockItems:
aHandle.append(nwItem.itemHandle)
tree.append(nwItem)
assert len(tree) == len(mockItems)
bHandle = aHandle.copy()
bHandle[2], bHandle[3] = bHandle[3], bHandle[2]
assert aHandle != bHandle
assert tree.handles() == aHandle
tree.setOrder(bHandle)
assert tree.handles() == bHandle
caplog.clear()
tree.setOrder(bHandle + ["stuff"])
assert tree.handles() == bHandle
assert "Handle 'stuff' in new tree order is not in old order" in caplog.text
caplog.clear()
tree._order.append("stuff")
tree.setOrder(bHandle)
assert tree.handles() == bHandle
assert "Handle 'stuff' in old tree order is not in new order" in caplog.text
@pytest.mark.core
def testCoreTree_ToCFile(monkeypatch, fncPath, mockGUI, mockItems):
"""Test writing the ToC.txt file."""
project = NWProject()
tree = NWTree(project)
for nwItem in mockItems:
tree.append(nwItem)
tree.updateItemData(nwItem.itemHandle)
assert len(tree) == len(mockItems)
tree._order.append("stuff")
tree.unpack(mockItems)
def mockIsFile(fileName):
"""Return True for items that are files in novelWriter and
@@ -539,7 +424,7 @@ def testCoreTree_ToCFile(monkeypatch, fncPath, mockGUI, mockItems):
monkeypatch.setattr("pathlib.Path.is_file", mockIsFile)
project._storage._runtimePath = fncPath
(fncPath / "content").mkdir()
# (fncPath / "content").mkdir()
# Block extraction of the path
with monkeypatch.context() as mp:
@@ -553,11 +438,6 @@ def testCoreTree_ToCFile(monkeypatch, fncPath, mockGUI, mockItems):
# Allow writing
assert tree.writeToCFile() is True
pathA = str(Path("content") / "c000000000001.nwd")
pathB = str(Path("content") / "c000000000002.nwd")
pathC = str(Path("content") / "b000000000002.nwd")
assert (fncPath / nwFiles.TOC_TXT).read_text() == (
"\n"
"Table of Contents\n"
@@ -565,7 +445,7 @@ def testCoreTree_ToCFile(monkeypatch, fncPath, mockGUI, mockItems):
"\n"
"File Name Class Layout Document Label\n"
"--------------------------------------------------------------\n"
f"{pathA} NOVEL DOCUMENT Chapter One\n"
f"{pathB} NOVEL DOCUMENT Scene One\n"
f"{pathC} CHARACTER NOTE Jane Doe\n"
"content/000000000000c.nwd NOVEL DOCUMENT Title Page\n"
"content/000000000000e.nwd NOVEL DOCUMENT New Chapter\n"
"content/000000000000f.nwd NOVEL DOCUMENT New Scene\n"
)
-3
View File
@@ -38,8 +38,6 @@ def testDlgSplit_Main(qtbot, monkeypatch, nwGUI, projPath, mockRnd):
buildTestProject(nwGUI, projPath)
project = SHARED.project
projTree = nwGUI.projView.projTree
docText = (
"Text\n\n"
"##! Prologue\n\nText\n\n"
@@ -58,7 +56,6 @@ def testDlgSplit_Main(qtbot, monkeypatch, nwGUI, projPath, mockRnd):
hSplitDoc = project.newFile("Split Doc", C.hNovelRoot)
assert hSplitDoc is not None
project.writeNewFile(hSplitDoc, 1, True, docText)
projTree.revealNewTreeItem(hSplitDoc, nHandle=C.hNovelRoot, wordCount=True)
docText = f"# Split Doc\n\n{docText}"
@@ -161,7 +161,7 @@ def testDlgProjSettings_StatusImport(qtbot, monkeypatch, nwGUI, projPath, mockRn
project.tree[hCharNote].setImport(C.iMajor) # type: ignore
project.tree[hWorldNote].setImport(C.iMain) # type: ignore
nwGUI.projView.populateTree()
project.tree.refreshAllItems()
project.countStatus()
assert [e.count for _, e in project.data.itemStatus.iterItems()] == [2, 0, 2, 1]
+1 -1
View File
@@ -661,7 +661,7 @@ def testFmtToDocX_SaveDocument(mockGUI, prjLipsum, fncPath, tstPaths):
(0, True), (1, True), (2, True), (3, True), (4, True), (5, False),
(6, True), (7, True), (8, True), (9, False), (10, False), (11, True),
(12, True), (13, True), (14, True), (15, True), (16, True), (17, True),
(18, True), (19, True), (20, True),
(18, True), (19, True), (20, True), (21, False),
]
assert docPath.exists()
+76 -9
View File
@@ -24,22 +24,23 @@ from unittest.mock import MagicMock
import pytest
from PyQt5.QtCore import QEvent, Qt, QThreadPool, QUrl
from PyQt5.QtCore import QEvent, QMimeData, Qt, QThreadPool, QUrl
from PyQt5.QtGui import (
QClipboard, QDesktopServices, QFont, QMouseEvent, QTextBlock, QTextCursor,
QTextOption
QClipboard, QDesktopServices, QDragEnterEvent, QDragMoveEvent, QDropEvent,
QFont, QMouseEvent, QTextBlock, QTextCursor, QTextOption
)
from PyQt5.QtWidgets import QAction, QApplication, QMenu
from PyQt5.QtWidgets import QAction, QApplication, QMenu, QPlainTextEdit
from novelwriter import CONFIG, SHARED
from novelwriter.common import decodeMimeHandles
from novelwriter.constants import nwKeyWords, nwUnicode
from novelwriter.dialogs.editlabel import GuiEditLabel
from novelwriter.enum import nwDocAction, nwDocInsert, nwItemClass, nwItemLayout, nwTrinary
from novelwriter.gui.doceditor import GuiDocEditor
from novelwriter.text.counting import standardCounter
from novelwriter.types import (
QtAlignJustify, QtAlignLeft, QtKeepAnchor, QtModCtrl, QtMouseLeft,
QtMoveAnchor, QtMoveRight, QtScrollAlwaysOff, QtScrollAsNeeded
QtAlignJustify, QtAlignLeft, QtKeepAnchor, QtModCtrl, QtModNone,
QtMouseLeft, QtMoveAnchor, QtMoveRight, QtScrollAlwaysOff, QtScrollAsNeeded
)
from tests.mocked import causeOSError
@@ -82,7 +83,7 @@ def testGuiEditor_Init(qtbot, nwGUI, projPath, ipsumText, mockRnd):
assert docEditor.horizontalScrollBarPolicy() == QtScrollAsNeeded
assert docEditor._typConf.typPadChar == nwUnicode.U_NBSP
assert docEditor.docHeader.itemTitle.text() == (
"Novel \u203a New Chapter \u203a New Scene"
"Novel \u203a New Folder \u203a New Scene"
)
assert docEditor.docHeader._docOutline == {0: "### New Scene"}
@@ -209,6 +210,74 @@ def testGuiEditor_SaveText(qtbot, monkeypatch, caplog, nwGUI, projPath, ipsumTex
# qtbot.stop()
@pytest.mark.gui
def testGuiEditor_DragAndDrop(qtbot, monkeypatch, nwGUI, projPath, mockRnd):
"""Test drag and drop in the editor."""
docEditor = nwGUI.docEditor
buildTestProject(nwGUI, projPath)
assert nwGUI.openDocument(C.hTitlePage) is True
assert docEditor.docHandle == C.hTitlePage
middle = docEditor.viewport().rect().center()
action = Qt.DropAction.MoveAction
mouse = Qt.MouseButton.NoButton
model = SHARED.project.tree.model
docMime = model.mimeData([model.indexFromHandle(C.hSceneDoc)])
noneMime = QMimeData()
noneMime.setData("plain/text", b"")
assert decodeMimeHandles(docMime) == [C.hSceneDoc]
# Drag Enter
mockEnter = MagicMock()
docEvent = QDragEnterEvent(middle, action, docMime, mouse, QtModNone)
noneEvent = QDragEnterEvent(middle, action, noneMime, mouse, QtModNone)
with monkeypatch.context() as mp:
mp.setattr(QPlainTextEdit, "dragEnterEvent", mockEnter)
# Document Enter
docEditor.dragEnterEvent(docEvent)
assert docEvent.isAccepted() is True
assert mockEnter.call_count == 0
# Regular Enter
docEditor.dragEnterEvent(noneEvent)
assert mockEnter.call_count == 1
# Drag Move
mockMove = MagicMock()
docEvent = QDragMoveEvent(middle, action, docMime, mouse, QtModNone)
noneEvent = QDragMoveEvent(middle, action, noneMime, mouse, QtModNone)
with monkeypatch.context() as mp:
mp.setattr(QPlainTextEdit, "dragMoveEvent", mockMove)
# Document Move
docEditor.dragMoveEvent(docEvent)
assert docEvent.isAccepted() is True
assert mockMove.call_count == 0
# Regular Move
docEditor.dragMoveEvent(noneEvent)
assert mockMove.call_count == 1
# Drop
mockDrop = MagicMock()
docEvent = QDropEvent(middle, action, docMime, mouse, QtModNone)
noneEvent = QDropEvent(middle, action, noneMime, mouse, QtModNone)
with monkeypatch.context() as mp:
mp.setattr(QPlainTextEdit, "dropEvent", mockDrop)
# Document Drop
docEditor.dropEvent(docEvent)
assert mockDrop.call_count == 0
assert docEditor.docHandle == C.hSceneDoc
# Regular Move
docEditor.dropEvent(noneEvent)
assert mockDrop.call_count == 1
@pytest.mark.gui
def testGuiEditor_MetaData(qtbot, nwGUI, projPath, mockRnd):
"""Test extracting various meta data and other values."""
@@ -1615,7 +1684,6 @@ def testGuiEditor_Tags(qtbot, nwGUI, projPath, ipsumText, mockRnd):
assert nwGUI.openDocument(cHandle) is True
docEditor.replaceText(text)
nwGUI.saveDocument()
assert nwGUI.projView.projTree.revealNewTreeItem(cHandle)
# Follow Tag
# ==========
@@ -1715,7 +1783,6 @@ def testGuiEditor_Completer(qtbot, nwGUI, projPath, mockRnd):
assert nwGUI.openDocument(cHandle) is True
docEditor.replaceText(text)
nwGUI.saveDocument()
assert nwGUI.projView.projTree.revealNewTreeItem(cHandle)
docEditor.replaceText("")
completer = docEditor._completer
+82 -8
View File
@@ -24,16 +24,21 @@ from unittest.mock import MagicMock
import pytest
from PyQt5.QtCore import QEvent, QPoint, Qt, QUrl
from PyQt5.QtGui import QDesktopServices, QMouseEvent, QTextCursor
from PyQt5.QtWidgets import QAction, QApplication, QMenu
from PyQt5.QtCore import QEvent, QMimeData, QPoint, Qt, QUrl
from PyQt5.QtGui import (
QDesktopServices, QDragEnterEvent, QDragMoveEvent, QDropEvent, QMouseEvent,
QTextCursor
)
from PyQt5.QtWidgets import QAction, QApplication, QMenu, QTextBrowser
from novelwriter import CONFIG, SHARED
from novelwriter.enum import nwDocAction
from novelwriter.common import decodeMimeHandles
from novelwriter.enum import nwChange, nwDocAction
from novelwriter.formats.toqdoc import ToQTextDocument
from novelwriter.types import QtModNone, QtMouseLeft
from tests.mocked import causeException
from tests.tools import C, buildTestProject
@pytest.mark.gui
@@ -52,8 +57,8 @@ def testGuiViewer_Main(qtbot, monkeypatch, nwGUI, prjLipsum):
assert docViewer.loadText("b3643d0f92e32") is False
# Middle-click the selected item
item = nwGUI.projView.projTree._getTreeItem("88243afbe5ed8")
rect = nwGUI.projView.projTree.visualItemRect(item)
index = SHARED.project.tree.model.indexFromHandle("88243afbe5ed8")
rect = nwGUI.projView.projTree.visualRect(index)
qtbot.mouseClick(nwGUI.projView.projTree.viewport(), Qt.MidButton, pos=rect.center())
assert docViewer.docHandle == "88243afbe5ed8"
@@ -206,12 +211,12 @@ def testGuiViewer_Main(qtbot, monkeypatch, nwGUI, prjLipsum):
nwItem = SHARED.project.tree["4c4f28287af27"]
nwItem.setName("Test Title") # type: ignore
assert nwItem.itemName == "Test Title" # type: ignore
docViewer.updateDocInfo("4c4f28287af27")
docViewer.onProjectItemChanged("4c4f28287af27", nwChange.UPDATE)
assert docViewer.docHeader.itemTitle.text() == "Characters \u203a Test Title"
# Title without full path
CONFIG.showFullPath = False
docViewer.updateDocInfo("4c4f28287af27")
docViewer.onProjectItemChanged("4c4f28287af27", nwChange.UPDATE)
assert docViewer.docHeader.itemTitle.text() == "Test Title"
CONFIG.showFullPath = True
@@ -238,3 +243,72 @@ def testGuiViewer_Main(qtbot, monkeypatch, nwGUI, prjLipsum):
docViewer.updateTheme()
# qtbot.stop()
@pytest.mark.gui
def testGuiViewer_DragAndDrop(qtbot, monkeypatch, nwGUI, projPath, mockRnd):
"""Test drag and drop in the viewer."""
docViewer = nwGUI.docViewer
buildTestProject(nwGUI, projPath)
assert nwGUI.openDocument(C.hTitlePage) is True
assert nwGUI.viewDocument(C.hTitlePage) is True
assert docViewer.docHandle == C.hTitlePage
middle = docViewer.viewport().rect().center()
action = Qt.DropAction.MoveAction
mouse = Qt.MouseButton.NoButton
model = SHARED.project.tree.model
docMime = model.mimeData([model.indexFromHandle(C.hSceneDoc)])
noneMime = QMimeData()
noneMime.setData("plain/text", b"")
assert decodeMimeHandles(docMime) == [C.hSceneDoc]
# Drag Enter
mockEnter = MagicMock()
docEvent = QDragEnterEvent(middle, action, docMime, mouse, QtModNone)
noneEvent = QDragEnterEvent(middle, action, noneMime, mouse, QtModNone)
with monkeypatch.context() as mp:
mp.setattr(QTextBrowser, "dragEnterEvent", mockEnter)
# Document Enter
docViewer.dragEnterEvent(docEvent)
assert docEvent.isAccepted() is True
assert mockEnter.call_count == 0
# Regular Enter
docViewer.dragEnterEvent(noneEvent)
assert mockEnter.call_count == 1
# Drag Move
mockMove = MagicMock()
docEvent = QDragMoveEvent(middle, action, docMime, mouse, QtModNone)
noneEvent = QDragMoveEvent(middle, action, noneMime, mouse, QtModNone)
with monkeypatch.context() as mp:
mp.setattr(QTextBrowser, "dragMoveEvent", mockMove)
# Document Move
docViewer.dragMoveEvent(docEvent)
assert docEvent.isAccepted() is True
assert mockMove.call_count == 0
# Regular Move
docViewer.dragMoveEvent(noneEvent)
assert mockMove.call_count == 1
# Drop
mockDrop = MagicMock()
docEvent = QDropEvent(middle, action, docMime, mouse, QtModNone)
noneEvent = QDropEvent(middle, action, noneMime, mouse, QtModNone)
with monkeypatch.context() as mp:
mp.setattr(QTextBrowser, "dropEvent", mockDrop)
# Document Drop
docViewer.dropEvent(docEvent)
assert mockDrop.call_count == 0
assert docViewer.docHandle == C.hSceneDoc
# Regular Move
docViewer.dropEvent(noneEvent)
assert mockDrop.call_count == 1
+9 -12
View File
@@ -38,8 +38,7 @@ def testGuiViewerPanel_BackRefs(qtbot, monkeypatch, nwGUI, projPath, mockRnd):
monkeypatch.setattr(GuiEditLabel, "getLabel", lambda *a, text: (text, True))
buildTestProject(nwGUI, projPath)
projTree = nwGUI.projView.projTree
projTree._getTreeItem(C.hChapterDir).setExpanded(True)
nwGUI.projView.projTree.expandAll()
viewPanel = nwGUI.docViewerPanel
tabBackRefs = viewPanel.tabBackRefs
@@ -81,17 +80,17 @@ def testGuiViewerPanel_BackRefs(qtbot, monkeypatch, nwGUI, projPath, mockRnd):
# Update Label
SHARED.project.tree[C.hSceneDoc].setName("First Scene") # type: ignore
projTree.renameTreeItem(C.hSceneDoc)
nwGUI.projView.renameTreeItem(C.hSceneDoc)
item = tabBackRefs.topLevelItem(0)
assert item.text(tabBackRefs.C_DOC) == "First Scene"
assert item.text(tabBackRefs.C_TITLE) == "Scene One"
# Clear Index
SHARED.project.index.clearIndex()
SHARED.project.index.clear()
assert tabBackRefs.topLevelItemCount() == 0
# Rebuild Index
SHARED.project.index.rebuildIndex()
SHARED.project.index.rebuild()
assert tabBackRefs.topLevelItemCount() == 1
# Test Update Theme
@@ -127,8 +126,7 @@ def testGuiViewerPanel_Tags(qtbot, monkeypatch, caplog, nwGUI, projPath, mockRnd
monkeypatch.setattr(GuiEditLabel, "getLabel", lambda *a, text: (text, True))
buildTestProject(nwGUI, projPath)
projTree = nwGUI.projView.projTree
projTree._getTreeItem(C.hChapterDir).setExpanded(True)
nwGUI.projView.projTree.expandAll()
viewPanel = nwGUI.docViewerPanel
nwGUI.openDocument(C.hSceneDoc)
@@ -175,18 +173,18 @@ def testGuiViewerPanel_Tags(qtbot, monkeypatch, caplog, nwGUI, projPath, mockRnd
nwGUI.docEditor.setPlainText("# Jane Smith\n\n@tag: Janey\n\n")
nwGUI.saveDocument()
SHARED.project.tree[hJane].setName("Awesome Jane") # type: ignore
projTree.renameTreeItem(hJane)
nwGUI.projView.renameTreeItem(hJane)
item = charTab.topLevelItem(0)
assert item.text(charTab.C_NAME) == "Janey"
assert item.text(charTab.C_DOC) == "Awesome Jane"
assert item.text(charTab.C_TITLE) == "Jane Smith"
# Clear Index
SHARED.project.index.clearIndex()
SHARED.project.index.clear()
assert charTab.topLevelItemCount() == 0
# Rebuild Index
SHARED.project.index.rebuildIndex()
SHARED.project.index.rebuild()
assert charTab.topLevelItemCount() == 2
# Test Update Theme
@@ -221,8 +219,7 @@ def testGuiViewerPanel_Tags(qtbot, monkeypatch, caplog, nwGUI, projPath, mockRnd
nwJohn = SHARED.project.tree[hJohn]
assert isinstance(nwJohn, NWItem)
nwJohn.setActive(False)
projTree.setTreeItemValues(nwJohn)
projTree._alertTreeChange(hJohn, flush=False)
nwJohn.notifyToRefresh()
assert charTab.topLevelItemCount() == 1
# Update Labels
+44 -33
View File
@@ -144,7 +144,7 @@ def testGuiMain_ProjectTreeItems(qtbot, monkeypatch, nwGUI, projPath, mockRnd):
with monkeypatch.context() as mp:
mp.setattr(GuiProjectTree, "hasFocus", lambda *a: True)
assert nwGUI.docEditor.docHandle is None
nwGUI.projView.projTree._getTreeItem(sHandle).setSelected(True)
nwGUI.projView.projTree.setSelectedHandle(sHandle)
nwGUI._keyPressReturn()
assert nwGUI.docEditor.docHandle == sHandle
nwGUI.closeDocument()
@@ -210,9 +210,8 @@ def testGuiMain_Editing(qtbot, monkeypatch, nwGUI, projPath, tstPaths, mockRnd):
assert nwGUI.closeProject()
assert len(SHARED.project.tree) == 0
assert len(SHARED.project.tree._order) == 0
assert len(SHARED.project.tree._roots) == 0
assert SHARED.project.tree.trashRoot is None
assert len(SHARED.project.tree._items) == 0
assert len(SHARED.project.tree._nodes) == 0
assert SHARED.project.data.name == ""
assert SHARED.project.data.author == ""
assert SHARED.project.data.spellCheck is False
@@ -228,23 +227,22 @@ def testGuiMain_Editing(qtbot, monkeypatch, nwGUI, projPath, tstPaths, mockRnd):
assert nwGUI.openProject(projPath)
# Check that we loaded the data
assert len(SHARED.project.tree) == 8
assert len(SHARED.project.tree._order) == 8
assert len(SHARED.project.tree._roots) == 4
assert SHARED.project.tree.trashRoot is None
assert len(SHARED.project.tree) == 9
assert SHARED.project.tree.model.root.childCount() == 5
assert SHARED.project.tree.trash is not None # Created automatically
assert SHARED.project.data.name == "New Project"
assert SHARED.project.data.author == "Jane Doe"
assert SHARED.project.data.spellCheck is False
# Check that tree items have been created
assert nwGUI.projView.projTree._getTreeItem(C.hNovelRoot) is not None
assert nwGUI.projView.projTree._getTreeItem(C.hPlotRoot) is not None
assert nwGUI.projView.projTree._getTreeItem(C.hCharRoot) is not None
assert nwGUI.projView.projTree._getTreeItem(C.hWorldRoot) is not None
assert nwGUI.projView.projTree._getTreeItem(C.hTitlePage) is not None
assert nwGUI.projView.projTree._getTreeItem(C.hChapterDir) is not None
assert nwGUI.projView.projTree._getTreeItem(C.hChapterDoc) is not None
assert nwGUI.projView.projTree._getTreeItem(C.hSceneDoc) is not None
assert SHARED.project.tree[C.hNovelRoot] is not None
assert SHARED.project.tree[C.hPlotRoot] is not None
assert SHARED.project.tree[C.hCharRoot] is not None
assert SHARED.project.tree[C.hWorldRoot] is not None
assert SHARED.project.tree[C.hTitlePage] is not None
assert SHARED.project.tree[C.hChapterDir] is not None
assert SHARED.project.tree[C.hChapterDoc] is not None
assert SHARED.project.tree[C.hSceneDoc] is not None
nwGUI.mainMenu.aSpellCheck.setChecked(True)
nwGUI.mainMenu._toggleSpellCheck()
@@ -256,10 +254,12 @@ def testGuiMain_Editing(qtbot, monkeypatch, nwGUI, projPath, tstPaths, mockRnd):
CONFIG.autoScroll = True
# Add a Character File
nwGUI._switchFocus(nwFocus.TREE)
nwGUI._changeView(nwView.PROJECT)
nwGUI.projView.projTree.expandAll()
nwGUI.projView.projTree.clearSelection()
nwGUI.projView.projTree._getTreeItem(C.hCharRoot).setSelected(True)
nwGUI.projView.projTree.setSelectedHandle(C.hCharRoot)
nwGUI.projView.projTree.newTreeItem(nwItemType.FILE, None, isNote=True)
nwGUI.projView.projTree.expandAll()
nwGUI.openSelectedItem()
# Text Editor
@@ -290,10 +290,11 @@ def testGuiMain_Editing(qtbot, monkeypatch, nwGUI, projPath, tstPaths, mockRnd):
qtbot.keyClick(docEditor, Qt.Key.Key_Return, delay=KEY_DELAY)
# Add a Plot File
nwGUI._switchFocus(nwFocus.TREE)
nwGUI.projView.projTree.expandAll()
nwGUI.projView.projTree.clearSelection()
nwGUI.projView.projTree._getTreeItem(C.hPlotRoot).setSelected(True)
nwGUI.projView.projTree.setSelectedHandle(C.hPlotRoot)
nwGUI.projView.projTree.newTreeItem(nwItemType.FILE, None, isNote=True)
nwGUI.projView.projTree.expandAll()
nwGUI.openSelectedItem()
# Type something into the document
@@ -312,10 +313,11 @@ def testGuiMain_Editing(qtbot, monkeypatch, nwGUI, projPath, tstPaths, mockRnd):
qtbot.keyClick(docEditor, Qt.Key.Key_Return, delay=KEY_DELAY)
# Add a World File
nwGUI._switchFocus(nwFocus.TREE)
nwGUI.projView.projTree.expandAll()
nwGUI.projView.projTree.clearSelection()
nwGUI.projView.projTree._getTreeItem(C.hWorldRoot).setSelected(True)
nwGUI.projView.projTree.setSelectedHandle(C.hWorldRoot)
nwGUI.projView.projTree.newTreeItem(nwItemType.FILE, None, isNote=True)
nwGUI.projView.projTree.expandAll()
nwGUI.openSelectedItem()
# Add Some Text
@@ -343,11 +345,9 @@ def testGuiMain_Editing(qtbot, monkeypatch, nwGUI, projPath, tstPaths, mockRnd):
nwGUI._autoSaveProject()
# Select the 'New Scene' file
nwGUI._switchFocus(nwFocus.TREE)
nwGUI.projView.projTree.expandAll()
nwGUI.projView.projTree.clearSelection()
nwGUI.projView.projTree._getTreeItem(C.hNovelRoot).setExpanded(True)
nwGUI.projView.projTree._getTreeItem(C.hChapterDir).setExpanded(True)
nwGUI.projView.projTree._getTreeItem(C.hSceneDoc).setSelected(True)
nwGUI.projView.projTree.setSelectedHandle(C.hSceneDoc)
nwGUI.openSelectedItem()
# Type something into the document
@@ -533,6 +533,7 @@ def testGuiMain_Editing(qtbot, monkeypatch, nwGUI, projPath, tstPaths, mockRnd):
# Indent and Align
# ================
nwGUI._switchFocus(nwView.EDITOR)
for c in "\t\"Tab-indented text\"":
qtbot.keyClick(docEditor, c, delay=KEY_DELAY)
qtbot.keyClick(docEditor, Qt.Key.Key_Return, delay=KEY_DELAY)
@@ -622,12 +623,6 @@ def testGuiMain_Editing(qtbot, monkeypatch, nwGUI, projPath, tstPaths, mockRnd):
copyfile(projFile, testFile)
assert cmpFiles(testFile, compFile, ignoreStart=NWD_IGNORE)
projFile = projPath / "content" / "0000000000010.nwd"
testFile = tstPaths.outDir / "guiEditor_Main_Final_0000000000010.nwd"
compFile = tstPaths.refDir / "guiEditor_Main_Final_0000000000010.nwd"
copyfile(projFile, testFile)
assert cmpFiles(testFile, compFile, ignoreStart=NWD_IGNORE)
projFile = projPath / "content" / "0000000000011.nwd"
testFile = tstPaths.outDir / "guiEditor_Main_Final_0000000000011.nwd"
compFile = tstPaths.refDir / "guiEditor_Main_Final_0000000000011.nwd"
@@ -640,6 +635,12 @@ def testGuiMain_Editing(qtbot, monkeypatch, nwGUI, projPath, tstPaths, mockRnd):
copyfile(projFile, testFile)
assert cmpFiles(testFile, compFile, ignoreStart=NWD_IGNORE)
projFile = projPath / "content" / "0000000000013.nwd"
testFile = tstPaths.outDir / "guiEditor_Main_Final_0000000000013.nwd"
compFile = tstPaths.refDir / "guiEditor_Main_Final_0000000000013.nwd"
copyfile(projFile, testFile)
assert cmpFiles(testFile, compFile, ignoreStart=NWD_IGNORE)
# qtbot.stop()
@@ -701,6 +702,17 @@ def testGuiMain_Viewing(qtbot, monkeypatch, nwGUI, projPath, mockRnd):
nwGUI.viewDocument(C.hSceneDoc)
assert nwGUI.docViewer.toPlainText() == "New Scene\nWith some stuff in it!"
# Open with keypress
nwGUI.closeDocViewer()
assert nwGUI.docViewer.docHandle is None
nwGUI.projView.setSelectedHandle(C.hSceneDoc)
with monkeypatch.context() as mp:
mp.setattr(nwGUI.projView.projTree, "hasFocus", lambda *a: True)
qtbot.keyClick(
nwGUI.projView.projTree, Qt.Key.Key_Return, modifier=QtModShift, delay=KEY_DELAY
)
assert nwGUI.docViewer.docHandle == C.hSceneDoc
# qtbot.stop()
@@ -711,7 +723,6 @@ def testGuiMain_Features(qtbot, monkeypatch, nwGUI, projPath, mockRnd):
cHandle = SHARED.project.newFile("Jane", C.hCharRoot)
newDoc = SHARED.project.storage.getDocument(cHandle)
newDoc.writeDocument("# Jane\n\n@tag: Jane\n\n")
nwGUI.projView.projTree.revealNewTreeItem(cHandle)
nwGUI.rebuildIndex(beQuiet=True)
assert SHARED.focusMode is False
-1
View File
@@ -374,7 +374,6 @@ def testGuiMainMenu_Insert(qtbot, monkeypatch, nwGUI, fncPath, projPath, mockRnd
"""Test the Insert menu."""
buildTestProject(nwGUI, projPath)
assert nwGUI.projView.projTree._getTreeItem(C.hSceneDoc) is not None
assert nwGUI.openDocument(C.hSceneDoc) is True
mainMenu = nwGUI.mainMenu
docEditor = nwGUI.docEditor
+1 -1
View File
@@ -46,7 +46,7 @@ def testGuiNovelTree_TreeItems(qtbot, monkeypatch, nwGUI, projPath, mockRnd):
nwGUI._switchFocus(nwFocus.TREE)
nwGUI.projView.projTree.clearSelection()
nwGUI.projView.projTree._getTreeItem(C.hCharRoot).setSelected(True)
nwGUI.projView.projTree.setSelectedHandle(C.hCharRoot)
nwGUI.projView.projTree.newTreeItem(nwItemType.FILE)
contentPath = SHARED.project.storage.contentPath
+3 -4
View File
@@ -197,8 +197,8 @@ def testGuiOutline_Content(qtbot, monkeypatch, nwGUI, prjLipsum, fncPath, tstPat
assert outlineBar.novelValue.itemData(2) == "" # All novels
# Add a second novel folder
newHandle = SHARED.project.newRoot(nwItemClass.NOVEL)
nwGUI.projView.projTree.revealNewTreeItem(newHandle)
with qtbot.waitSignal(SHARED.rootFolderChanged):
newHandle = SHARED.project.newRoot(nwItemClass.NOVEL)
# Check new values in dropdown list
assert outlineBar.novelValue.itemData(0) == lipHandle
@@ -217,7 +217,6 @@ def testGuiOutline_Content(qtbot, monkeypatch, nwGUI, prjLipsum, fncPath, tstPat
aHandle = SHARED.project.newFile(dTitle, newHandle)
hHash = "#"*hLevel
writeFile(prjLipsum / "content" / f"{aHandle}.nwd", f"{hHash} {dTitle}\n\n")
nwGUI.projView.projTree.revealNewTreeItem(aHandle)
nwGUI.rebuildIndex()
@@ -279,7 +278,7 @@ def testGuiOutline_Content(qtbot, monkeypatch, nwGUI, prjLipsum, fncPath, tstPat
assert outlineData.fileValue.text() == "Scene One"
assert outlineData.itemValue.text() == "Finished"
outlineTree._treeDoubleClick(selItem, 0)
outlineTree._onItemDoubleClicked(selItem, 0)
assert nwGUI.docEditor.docHandle == "88243afbe5ed8"
# Dump to CSV
File diff suppressed because it is too large Load Diff
+10 -5
View File
@@ -31,13 +31,12 @@ from tests.tools import C, buildTestProject
@pytest.mark.gui
def testGuiStatusBar_Main(qtbot, nwGUI, projPath, mockRnd):
def testGuiStatusBar_Main(qtbot, monkeypatch, nwGUI, projPath, mockRnd):
"""Test the the various features of the status bar."""
buildTestProject(nwGUI, projPath)
cHandle = SHARED.project.newFile("A Note", C.hCharRoot)
newDoc = SHARED.project.storage.getDocument(cHandle)
newDoc.writeDocument("# A Note\n\n")
nwGUI.projView.projTree.revealNewTreeItem(cHandle)
nwGUI.rebuildIndex(beQuiet=True)
# Reference Time
@@ -87,10 +86,16 @@ def testGuiStatusBar_Main(qtbot, nwGUI, projPath, mockRnd):
# Project Stats
CONFIG.incNotesWCount = False
nwGUI._lastTotalCount = 0
nwGUI._updateStatusWordCount()
assert nwGUI.mainStatus.statsText.text() == "Words: 9 (+9)"
CONFIG.incNotesWCount = True
nwGUI._updateStatusWordCount()
assert nwGUI.mainStatus.statsText.text() == "Words: 11 (+11)"
# Update again, but through time tick
with monkeypatch.context() as mp:
mp.setattr("novelwriter.guimain.time", lambda *a: 50.0)
CONFIG.incNotesWCount = True
nwGUI._lastTotalCount = 0
nwGUI._timeTick()
assert nwGUI.mainStatus.statsText.text() == "Words: 11 (+11)"
# qtbot.stop()
+8 -2
View File
@@ -121,8 +121,6 @@ def testToolBuildSettings_Filter(qtbot, nwGUI, projPath, mockRnd):
hPlotDoc = SHARED.project.newFile("Main Plot", C.hPlotRoot)
hCharDoc = SHARED.project.newFile("Jane Doe", C.hCharRoot)
nwGUI.projView.projTree.revealNewTreeItem(hPlotDoc)
nwGUI.projView.projTree.revealNewTreeItem(hCharDoc)
SHARED.project.tree[hPlotDoc].setActive(False) # type: ignore
# Create the dialog and populate it
@@ -169,6 +167,7 @@ def testToolBuildSettings_Filter(qtbot, nwGUI, projPath, mockRnd):
C.hCharRoot: (False, FilterMode.SKIPPED),
hCharDoc: (False, FilterMode.FILTERED),
C.hWorldRoot: (False, FilterMode.SKIPPED),
C.hTrashRoot: (False, FilterMode.SKIPPED),
}
# Switch on note docs
@@ -184,6 +183,7 @@ def testToolBuildSettings_Filter(qtbot, nwGUI, projPath, mockRnd):
C.hCharRoot: (False, FilterMode.SKIPPED),
hCharDoc: (True, FilterMode.FILTERED), # Now enabled
C.hWorldRoot: (False, FilterMode.SKIPPED),
C.hTrashRoot: (False, FilterMode.SKIPPED),
}
# Switch on inactive docs
@@ -199,6 +199,7 @@ def testToolBuildSettings_Filter(qtbot, nwGUI, projPath, mockRnd):
C.hCharRoot: (False, FilterMode.SKIPPED),
hCharDoc: (True, FilterMode.FILTERED),
C.hWorldRoot: (False, FilterMode.SKIPPED),
C.hTrashRoot: (False, FilterMode.SKIPPED),
}
# Set chapter and scene docs to included
@@ -216,6 +217,7 @@ def testToolBuildSettings_Filter(qtbot, nwGUI, projPath, mockRnd):
C.hCharRoot: (False, FilterMode.SKIPPED),
hCharDoc: (True, FilterMode.FILTERED),
C.hWorldRoot: (False, FilterMode.SKIPPED),
C.hTrashRoot: (False, FilterMode.SKIPPED),
}
# Set char and plot docs to excluded
@@ -234,6 +236,7 @@ def testToolBuildSettings_Filter(qtbot, nwGUI, projPath, mockRnd):
C.hCharRoot: (False, FilterMode.SKIPPED),
hCharDoc: (False, FilterMode.EXCLUDED), # Now excluded
C.hWorldRoot: (False, FilterMode.SKIPPED),
C.hTrashRoot: (False, FilterMode.SKIPPED),
}
# Switch on novel docs
@@ -249,6 +252,7 @@ def testToolBuildSettings_Filter(qtbot, nwGUI, projPath, mockRnd):
C.hCharRoot: (False, FilterMode.SKIPPED),
hCharDoc: (False, FilterMode.EXCLUDED),
C.hWorldRoot: (False, FilterMode.SKIPPED),
C.hTrashRoot: (False, FilterMode.SKIPPED),
}
# Selecting only novel root should iterate through all children
@@ -266,6 +270,7 @@ def testToolBuildSettings_Filter(qtbot, nwGUI, projPath, mockRnd):
C.hCharRoot: (False, FilterMode.SKIPPED),
hCharDoc: (False, FilterMode.EXCLUDED),
C.hWorldRoot: (False, FilterMode.SKIPPED),
C.hTrashRoot: (False, FilterMode.SKIPPED),
}
# Set everything back to filtered
@@ -286,6 +291,7 @@ def testToolBuildSettings_Filter(qtbot, nwGUI, projPath, mockRnd):
C.hCharRoot: (False, FilterMode.SKIPPED),
hCharDoc: (True, FilterMode.FILTERED),
C.hWorldRoot: (False, FilterMode.SKIPPED),
C.hTrashRoot: (False, FilterMode.SKIPPED),
}
# Check handling of invalid project items
+4 -3
View File
@@ -38,12 +38,13 @@ def testToolNovelDetails_Main(qtbot, nwGUI, prjLipsum, ipsumText):
# Add a second Novel folder
project = SHARED.project
secondText = "#! Second\n\n" + "\n\n".join(ipsumText)
sHandle = project.newRoot(nwItemClass.NOVEL, "Second")
sHandle = project.newRoot(nwItemClass.NOVEL)
dHandle = project.newFile("Document", sHandle)
if item := project.tree[sHandle]:
item.setName("Second")
project.storage.getDocument(dHandle).writeDocument(secondText)
project.index.reIndexHandle(dHandle)
nwGUI.projView.projTree.revealNewTreeItem(sHandle)
nwGUI.projView.projTree.revealNewTreeItem(dHandle)
# Create the dialog
nwGUI.mainMenu.aNovelDetails.activate(QAction.ActionEvent.Trigger)
+7 -6
View File
@@ -52,6 +52,7 @@ class C:
hInvalid = "0000000000000"
hNovelRoot = "0000000000008"
hPlotRoot = "0000000000009"
hTrashRoot = "0000000000010"
hCharRoot = "000000000000a"
hWorldRoot = "000000000000b"
hTitlePage = "000000000000c"
@@ -181,13 +182,13 @@ def buildTestProject(obj: object, projPath: Path) -> None:
# Creating a minimal project with a few root folders and a
# single chapter folder with a single file.
nrHandle = project.newRoot(nwItemClass.NOVEL, "Novel")
project.newRoot(nwItemClass.PLOT, "Plot")
project.newRoot(nwItemClass.CHARACTER, "Characters")
project.newRoot(nwItemClass.WORLD, "World")
nrHandle = project.newRoot(nwItemClass.NOVEL)
project.newRoot(nwItemClass.PLOT)
project.newRoot(nwItemClass.CHARACTER)
project.newRoot(nwItemClass.WORLD)
tdHandle = project.newFile("Title Page", nrHandle)
cfHandle = project.newFolder("New Chapter", nrHandle) or ""
cfHandle = project.newFolder("New Folder", nrHandle) or ""
cdHandle = project.newFile("New Chapter", cfHandle)
sdHandle = project.newFile("New Scene", cfHandle)
@@ -209,7 +210,7 @@ def buildTestProject(obj: object, projPath: Path) -> None:
project._valid = True
if nwGUI is not None:
nwGUI.projView.populateTree()
nwGUI.projView.openProjectTasks()
return