Files
novelWriter/novelwriter/core/tree.py
T
Veronica Berglyd Olsen c174f8f931 Update linting for main code
2025-08-27 21:00:09 +02:00

537 lines
20 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
novelWriter Project Tree Class
================================
File History:
Created: 2020-05-07 [0.4.5] NWTree
Rewritten: 2024-11-16 [2.6b2] NWTree
This file is a part of novelWriter
Copyright (C) 2020 Veronica Berglyd Olsen and novelWriter contributors
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/>.
""" # noqa
from __future__ import annotations
import logging
import random
from pathlib import Path
from typing import TYPE_CHECKING, Literal, overload
from PyQt6.QtCore import QModelIndex
from novelwriter import SHARED
from novelwriter.constants import nwFiles, nwLabels, nwStyles, trConst
from novelwriter.core.item import NWItem
from novelwriter.core.itemmodel import ProjectModel, ProjectNode
from novelwriter.enum import nwChange, nwItemClass, nwItemLayout, nwItemType
from novelwriter.error import logException
if TYPE_CHECKING:
from collections.abc import Iterable, Iterator
from novelwriter.core.project import NWProject
logger = logging.getLogger(__name__)
MAX_DEPTH = 999 # Cap of tree traversing for loops (recursion limit)
class NWTree:
"""Core: Project Tree Data Class.
Only one instance of this class should exist in the project class.
This class holds all the project items of the project as instances
of NWItem.
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__ = ("_items", "_model", "_nodes", "_project", "_ready", "_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
self._ready = False
logger.debug("Ready: NWTree")
def __del__(self) -> None: # pragma: no cover
logger.debug("Delete: NWTree")
def __len__(self) -> int:
"""Return the number of items in the project."""
return len(self._items)
def __bool__(self) -> bool:
"""Return 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:
"""Check 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
##
# Properties
##
@property
def project(self) -> NWProject:
"""Return the parent project."""
return self._project
@property
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
##
def clear(self) -> None:
"""Clear the item tree entirely."""
oldModel = self._model
oldModel.clear()
self._model = ProjectModel(self)
self._items.clear()
self._nodes.clear()
self._trash = None
oldModel.deleteLater()
del oldModel
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, pos: int = -1
) -> str:
pass
@overload # pragma: no cover
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: 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._nodes:
tHandle = self._makeHandle()
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 duplicate(self, sHandle: str, pHandle: str | None, putAfter: bool) -> NWItem | None:
"""Duplicate an item and set a new handle."""
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
def pack(self) -> list[dict]:
"""Pack the content of the tree into a list of dictionaries of
items. In the order defined by the _treeOrder list.
"""
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, "")
if nwItem.unpack(item):
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._ready = True
self._model.endInsertRows()
self._model.layoutChanged.emit()
def pickParent(self, sNode: ProjectNode, hLevel: int, isNote: bool) -> tuple[str | None, int]:
"""Pick an appropriate parent handle for adding a new item."""
if sNode.item.isFolderType() or sNode.item.isRootType():
# Always add as a direct child of folders
return sNode.item.itemHandle, sNode.childCount()
pNode = sNode.parent()
pLevel = nwStyles.H_LEVEL.get(pNode.item.mainHeading, 0) if pNode else 0
# Notes are treated as H0, and scenes and sections both as H3
sLevel = min(0 if isNote else nwStyles.H_LEVEL.get(sNode.item.mainHeading, 0), 3)
if pNode and pNode.item.isFileType() and pLevel >= hLevel and sLevel > hLevel:
# If the selected item is a smaller heading and the parent heading
# is equal or larger, we make it a sibling of the parent (See #2260)
return pNode.item.itemParent, pNode.row() + 1
if sNode.childCount() > 0 and (0 < sLevel < hLevel or isNote):
# If the selected item already has child nodes and has a larger
# heading or is a note, we make the new item a child
return sNode.item.itemHandle, sNode.childCount()
# The default behaviour is to make the new item a sibling
return sNode.item.itemParent, sNode.row() + 1
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)
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()
def novelStructureChanged(self, tHandle: str) -> None:
"""Emit a novel structure change signal."""
if self._ready:
SHARED.novelStructureChanged.emit(tHandle)
def checkConsistency(self, prefix: str) -> tuple[int, int]:
"""Check the project tree consistency. Also check the content
folder and add back files that were discovered but were not
included in the tree. This function should only be called after
the project file has been processed, but before the loading of
the project returns. The functions requires a prefix string to
mark recovered files.
"""
storage = self._project.storage
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 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._nodes else None
oClass = oClass or nwItemClass.NOVEL
oLayout = oLayout or nwItemLayout.NOTE
# If the parent doesn't exists, find a new home
if oParent is None: # Add it to the first available class root
oParent = self.findRoot(oClass)
if oParent is None: # Otherwise, add to the Novel root
oParent = self.findRoot(nwItemClass.NOVEL)
if oParent is None: # If not, create a new novel folder
oParent = self.create(prefix, None, nwItemType.ROOT, nwItemClass.NOVEL)
assert oParent is not None # Otherwise there's an issue with self.create()
# Create a new item
newItem = NWItem(self._project, cHandle)
newItem.setName(f"[{prefix}] {oName}")
newItem.setParent(oParent)
newItem.setType(nwItemType.FILE)
newItem.setClass(oClass)
newItem.setLayout(oLayout)
if self.add(newItem):
recovered += 1
return orphans, recovered
def writeToCFile(self) -> bool:
"""Write the convenience table of contents file in the root of
the project directory.
"""
runtimePath = self._project.storage.runtimePath
contentPath = self._project.storage.contentPath
if not (isinstance(contentPath, Path) and isinstance(runtimePath, Path)):
return False
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(
f"content/{file}",
item.itemClass.name,
item.itemLayout.name,
item.itemName,
)
entries.append(tocLine)
maxLen = max(maxLen, len(tocLine))
try:
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"
))
toc.write("-"*max(maxLen, 62) + "\n")
toc.write("\n".join(entries))
toc.write("\n")
except Exception:
logger.error("Could not write ToC file")
logException()
return False
return True
def sumCounts(self) -> tuple[int, int, int, int]:
"""Loop over all entries and add up the word and char counts."""
novelWords = 0
notesWords = 0
novelChars = 0
notesChars = 0
for item in self._items.values():
if item.itemLayout == nwItemLayout.NOTE:
notesWords += item.wordCount
notesChars += item.charCount
elif item.itemLayout == nwItemLayout.DOCUMENT:
novelWords += item.wordCount
novelChars += item.charCount
return novelWords, notesWords, novelChars, notesChars
##
# Tree Item Methods
##
def checkType(self, tHandle: str, itemType: nwItemType) -> bool:
"""Check if item exists and is of the specified item type."""
if tItem := self[tHandle]:
return tItem.itemType == itemType
return False
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.
"""
path = []
if node := self._nodes.get(tHandle):
for _ in range(MAX_DEPTH):
if parent := node.parent():
path.append(node.item.itemName if asName else tHandle)
node = parent
else:
return path
logger.error("Max project tree depth reached")
return path
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
##
def rootClasses(self) -> set[nwItemClass]:
"""Return a set of all root classes in use by the project."""
rootClasses = set()
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 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 findRoot(self, itemClass: nwItemClass | None) -> str | None:
"""Find the first root item for a given class."""
for node in self._model.root.children:
if node.item.itemClass == itemClass:
return node.item.itemHandle
return None
##
# Internal Functions
##
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)
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._items:
logger.warning("Duplicate handle encountered! Retrying ...")
handle = self._makeHandle()
return handle