Rewrite project tree as a model/view set (#2119)
This commit is contained in:
@@ -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
|
||||
##
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 2018–2024, 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
@@ -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
|
||||
|
||||
@@ -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
@@ -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 2018–2024, 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()
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -75,6 +75,13 @@ class nwTrinary(Enum):
|
||||
POSITIVE = 1
|
||||
|
||||
|
||||
class nwChange(Enum):
|
||||
|
||||
CREATE = 0
|
||||
UPDATE = 1
|
||||
DELETE = 2
|
||||
|
||||
|
||||
class nwDocMode(Enum):
|
||||
|
||||
VIEW = 0
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 "")
|
||||
|
||||
@@ -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 "")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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 Jane’s 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 Jane’s 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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -0,0 +1,633 @@
|
||||
"""
|
||||
novelWriter – Item Model Tester
|
||||
===============================
|
||||
|
||||
This file is a part of novelWriter
|
||||
Copyright 2018–2024, 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()] == []
|
||||
@@ -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
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+869
-920
File diff suppressed because it is too large
Load Diff
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user