diff --git a/novelwriter/core/coretools.py b/novelwriter/core/coretools.py index 0f2f39c9..66bda582 100644 --- a/novelwriter/core/coretools.py +++ b/novelwriter/core/coretools.py @@ -258,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: @@ -509,8 +507,10 @@ class ProjectBuilder: f"%Short: {bfNote}\n\n" ) - # Also add the archive folder + # Also add the archive and trash folders project.newRoot(nwItemClass.ARCHIVE) + project.tree.trash # Triggers the creation of Trash + project.saveProject() project.closeProject() diff --git a/novelwriter/core/itemmodel.py b/novelwriter/core/itemmodel.py index 5f59c505..a26b15c4 100644 --- a/novelwriter/core/itemmodel.py +++ b/novelwriter/core/itemmodel.py @@ -232,6 +232,7 @@ class ProjectNode: else: child.item.setParent(None) child.item.setRoot(child.item.itemHandle) + child.item.setClassDefaults(child.item.itemClass) return diff --git a/novelwriter/core/project.py b/novelwriter/core/project.py index cc51ce35..2132de79 100644 --- a/novelwriter/core/project.py +++ b/novelwriter/core/project.py @@ -26,12 +26,10 @@ 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 from time import time -from typing import TYPE_CHECKING from PyQt5.QtCore import QCoreApplication @@ -51,9 +49,6 @@ from novelwriter.core.tree import NWTree from novelwriter.enum import nwItemClass, nwItemLayout, nwItemType from novelwriter.error import logException -if TYPE_CHECKING: # pragma: no cover - from novelwriter.core.item import NWItem - logger = logging.getLogger(__name__) @@ -209,7 +204,7 @@ 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 @@ -494,47 +489,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() diff --git a/novelwriter/core/tree.py b/novelwriter/core/tree.py index 59a0c3ac..207365f4 100644 --- a/novelwriter/core/tree.py +++ b/novelwriter/core/tree.py @@ -115,10 +115,6 @@ class NWTree: del oldModel return - def handles(self) -> list[str]: - """Returns a copy of the list of all the active handles.""" - return list(self._items.keys()) - def add(self, item: NWItem, pos: int = -1) -> bool: """Add a project item into the project tree.""" if pHandle := item.itemParent: @@ -128,6 +124,7 @@ class NWTree: self._model.insertChild(node, index, pos) self._nodes[item.itemHandle] = node self._items[item.itemHandle] = item + self._project.setProjectChanged(True) else: logger.error("Could not locate parent of '%s'", item.itemHandle) return False @@ -136,6 +133,7 @@ class NWTree: self._model.insertChild(node, QModelIndex(), pos) self._nodes[item.itemHandle] = node self._items[item.itemHandle] = item + self._project.setProjectChanged(True) else: logger.error("Invalid project item '%s'", item.itemHandle) return False @@ -181,18 +179,17 @@ class NWTree: nwItem.setType(itemType) nwItem.setClass(itemClass) if self.add(nwItem, pos): - self._project.setProjectChanged(True) return tHandle return None - 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): - # logger.info("Duplicated item '%s' -> '%s'", sHandle, nItem.itemHandle) - # return 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 def pack(self) -> list[dict]: @@ -416,22 +413,6 @@ class NWTree: 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 node in self._model.root.children: diff --git a/novelwriter/gui/projtree.py b/novelwriter/gui/projtree.py index 1a883906..3827e8a0 100644 --- a/novelwriter/gui/projtree.py +++ b/novelwriter/gui/projtree.py @@ -41,7 +41,7 @@ from PyQt5.QtWidgets import ( from novelwriter import CONFIG, SHARED from novelwriter.common import qtLambda from novelwriter.constants import nwLabels, nwStyles, nwUnicode, trConst -from novelwriter.core.coretools import DocMerger, DocSplitter +from novelwriter.core.coretools import DocDuplicator, DocMerger, DocSplitter from novelwriter.core.item import NWItem from novelwriter.core.itemmodel import ProjectModel, ProjectNode from novelwriter.dialogs.docmerge import GuiDocMerge @@ -594,11 +594,11 @@ class GuiProjectTree(QTreeView): def loadModel(self) -> None: """Load and prepare a new project model.""" - selModel = self.selectionModel() + # selModel = self.selectionModel() self.setModel(SHARED.project.tree.model) - if selModel: - selModel.deleteLater() - del selModel + # if selModel: + # selModel.deleteLater() + # del selModel # Lock the column sizes iPx = SHARED.theme.baseIconHeight @@ -814,6 +814,22 @@ class GuiProjectTree(QTreeView): return True + def duplicateFromHandle(self, tHandle: str) -> None: + """Duplicate the item hierarchy from a given item.""" + itemTree = [tHandle] + itemTree.extend(SHARED.project.tree.subTree(tHandle)) + if itemTree: + if len(itemTree) == 1: + question = self.tr("Do you want to duplicate this document?") + else: + question = self.tr("Do you want to duplicate this item and all child items?") + if SHARED.question(question): + docDup = DocDuplicator(SHARED.project) + dHandles = docDup.duplicate(itemTree) + if len(dHandles) != len(itemTree): + SHARED.warn(self.tr("Could not duplicate all items.")) + return + ## # Events ## @@ -1159,39 +1175,6 @@ class GuiProjectTree(QTreeView): # self._scrollTimer.stop() return - ## - # Internal Functions - ## - - def _duplicateFromHandle(self, tHandle: str) -> bool: - """Duplicate the item hierarchy from a given item.""" - # itemTree = self.getTreeFromHandle(tHandle) - # nItems = len(itemTree) - # if nItems == 0: - # return False - # elif nItems == 1: - # question = self.tr("Do you want to duplicate this document?") - # else: - # question = self.tr("Do you want to duplicate this item and all child items?") - - # if not SHARED.question(question): - # return False - - # docDup = DocDuplicator(SHARED.project) - # dupCount = 0 - # for dHandle, nHandle in docDup.duplicate(itemTree): - # SHARED.project.index.reIndexHandle(dHandle) - # self.revealNewTreeItem(dHandle, nHandle=nHandle, wordCount=True) - # self._alertTreeChange(dHandle, flush=False) - # dupCount += 1 - - # if dupCount != nItems: - # SHARED.warn(self.tr("Could not duplicate all items.")) - - # self.saveTreeOrder() - - return True - class _UpdatableMenu(QMenu): @@ -1324,7 +1307,7 @@ class _TreeContextMenu(QMenu): self._expandCollapse() if isFile: action = self.addAction(self.tr("Duplicate")) - action.triggered.connect(qtLambda(self._tree._duplicateFromHandle, self._handle)) + action.triggered.connect(qtLambda(self._tree.duplicateFromHandle, self._handle)) self._deleteOrTrash() return diff --git a/novelwriter/tools/manussettings.py b/novelwriter/tools/manussettings.py index 79dcf0bc..bae2b19d 100644 --- a/novelwriter/tools/manussettings.py +++ b/novelwriter/tools/manussettings.py @@ -419,8 +419,8 @@ class _FilterTab(NFixedPage): logger.debug("Building project tree") self._treeMap = {} self.optTree.clear() - for nwItem in SHARED.project.iterProjectItems(): - + for node in SHARED.project.tree.model.root.allChildren(): + nwItem = node.item tHandle = nwItem.itemHandle pHandle = nwItem.itemParent rHandle = nwItem.itemRoot @@ -429,8 +429,6 @@ class _FilterTab(NFixedPage): continue isFile = nwItem.isFileType() - isActive = nwItem.isActive - if nwItem.isInactiveClass() or not self._build.isRootAllowed(rHandle): continue @@ -439,18 +437,12 @@ class _FilterTab(NFixedPage): 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.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(): diff --git a/tests/tools.py b/tests/tools.py index 504eb5b9..bb34ada2 100644 --- a/tests/tools.py +++ b/tests/tools.py @@ -181,10 +181,10 @@ 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 ""