Re-implement project item duplication

This commit is contained in:
Veronica Berglyd Olsen
2024-11-20 18:48:14 +01:00
parent 072be78991
commit bbe8683d59
7 changed files with 58 additions and 147 deletions
+18 -18
View File
@@ -258,29 +258,27 @@ class DocDuplicator:
# Methods # 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 """Run through a list of items, duplicate them, and copy the
text content if they are documents. text content if they are documents.
""" """
result = []
after = True
if items: if items:
nHandle = items[0]
hMap: dict[str, str | None] = {t: None for t in items} hMap: dict[str, str | None] = {t: None for t in items}
for tHandle in items: for tHandle in items:
newItem = self._project.tree.duplicate(tHandle) if oldItem := self._project.tree[tHandle]:
if newItem is None: pHandle = hMap.get(oldItem.itemParent or "") or oldItem.itemParent
return if newItem := self._project.tree.duplicate(tHandle, pHandle, after):
hMap[tHandle] = newItem.itemHandle hMap[tHandle] = newItem.itemHandle
if newItem.itemParent in hMap: if newItem.isFileType():
newItem.setParent(hMap[newItem.itemParent]) self._project.copyFileContent(newItem.itemHandle, tHandle)
self._project.tree.updateItemData(newItem.itemHandle) newItem.notifyToRefresh()
if newItem.isFileType(): result.append(newItem.itemHandle)
newDoc = self._project.storage.getDocument(newItem.itemHandle) after = False
if newDoc.fileExists(): else:
return break
newDoc.writeDocument(self._project.storage.getDocumentText(tHandle)) return result
yield newItem.itemHandle, nHandle
nHandle = None
return
class DocSearch: class DocSearch:
@@ -509,8 +507,10 @@ class ProjectBuilder:
f"%Short: {bfNote}\n\n" f"%Short: {bfNote}\n\n"
) )
# Also add the archive folder # Also add the archive and trash folders
project.newRoot(nwItemClass.ARCHIVE) project.newRoot(nwItemClass.ARCHIVE)
project.tree.trash # Triggers the creation of Trash
project.saveProject() project.saveProject()
project.closeProject() project.closeProject()
+1
View File
@@ -232,6 +232,7 @@ class ProjectNode:
else: else:
child.item.setParent(None) child.item.setParent(None)
child.item.setRoot(child.item.itemHandle) child.item.setRoot(child.item.itemHandle)
child.item.setClassDefaults(child.item.itemClass)
return return
+1 -47
View File
@@ -26,12 +26,10 @@ from __future__ import annotations
import json import json
import logging import logging
from collections.abc import Iterable
from enum import Enum from enum import Enum
from functools import partial from functools import partial
from pathlib import Path from pathlib import Path
from time import time from time import time
from typing import TYPE_CHECKING
from PyQt5.QtCore import QCoreApplication from PyQt5.QtCore import QCoreApplication
@@ -51,9 +49,6 @@ from novelwriter.core.tree import NWTree
from novelwriter.enum import nwItemClass, nwItemLayout, nwItemType from novelwriter.enum import nwItemClass, nwItemLayout, nwItemType
from novelwriter.error import logException from novelwriter.error import logException
if TYPE_CHECKING: # pragma: no cover
from novelwriter.core.item import NWItem
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -209,7 +204,7 @@ class NWProject:
text = self._storage.getDocumentText(sHandle) text = self._storage.getDocumentText(sHandle)
self._storage.getDocument(tHandle).writeDocument(text) self._storage.getDocument(tHandle).writeDocument(text)
sItem.setLayout(tItem.itemLayout) sItem.setLayout(tItem.itemLayout)
self._index.scanText(tHandle, text) self._index.reIndexHandle(tHandle)
return True return True
@@ -494,47 +489,6 @@ class NWProject:
# Class Methods # 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: def updateWordCounts(self) -> None:
"""Update the total word count values.""" """Update the total word count values."""
novel, notes = self._tree.sumWords() novel, notes = self._tree.sumWords()
+9 -28
View File
@@ -115,10 +115,6 @@ class NWTree:
del oldModel del oldModel
return 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: def add(self, item: NWItem, pos: int = -1) -> bool:
"""Add a project item into the project tree.""" """Add a project item into the project tree."""
if pHandle := item.itemParent: if pHandle := item.itemParent:
@@ -128,6 +124,7 @@ class NWTree:
self._model.insertChild(node, index, pos) self._model.insertChild(node, index, pos)
self._nodes[item.itemHandle] = node self._nodes[item.itemHandle] = node
self._items[item.itemHandle] = item self._items[item.itemHandle] = item
self._project.setProjectChanged(True)
else: else:
logger.error("Could not locate parent of '%s'", item.itemHandle) logger.error("Could not locate parent of '%s'", item.itemHandle)
return False return False
@@ -136,6 +133,7 @@ class NWTree:
self._model.insertChild(node, QModelIndex(), pos) self._model.insertChild(node, QModelIndex(), pos)
self._nodes[item.itemHandle] = node self._nodes[item.itemHandle] = node
self._items[item.itemHandle] = item self._items[item.itemHandle] = item
self._project.setProjectChanged(True)
else: else:
logger.error("Invalid project item '%s'", item.itemHandle) logger.error("Invalid project item '%s'", item.itemHandle)
return False return False
@@ -181,18 +179,17 @@ class NWTree:
nwItem.setType(itemType) nwItem.setType(itemType)
nwItem.setClass(itemClass) nwItem.setClass(itemClass)
if self.add(nwItem, pos): if self.add(nwItem, pos):
self._project.setProjectChanged(True)
return tHandle return tHandle
return None 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.""" """Duplicate an item and set a new handle."""
# sItem = self.__getitem__(sHandle) if sNode := self._nodes.get(sHandle):
# if isinstance(sItem, NWItem): nItem = NWItem.duplicate(sNode.item, self._makeHandle())
# nItem = NWItem.duplicate(sItem, self._makeHandle()) nItem.setParent(pHandle)
# if self.append(nItem): if self.add(nItem, (sNode.row() + 1) if putAfter else -1):
# logger.info("Duplicated item '%s' -> '%s'", sHandle, nItem.itemHandle) logger.info("Duplicated item '%s' -> '%s'", sHandle, nItem.itemHandle)
# return nItem return nItem
return None return None
def pack(self) -> list[dict]: def pack(self) -> list[dict]:
@@ -416,22 +413,6 @@ class NWTree:
yield node.item.itemHandle, node.item yield node.item.itemHandle, node.item
return 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: def findRoot(self, itemClass: nwItemClass | None) -> str | None:
"""Find the first root item for a given class.""" """Find the first root item for a given class."""
for node in self._model.root.children: for node in self._model.root.children:
+22 -39
View File
@@ -41,7 +41,7 @@ from PyQt5.QtWidgets import (
from novelwriter import CONFIG, SHARED from novelwriter import CONFIG, SHARED
from novelwriter.common import qtLambda from novelwriter.common import qtLambda
from novelwriter.constants import nwLabels, nwStyles, nwUnicode, trConst 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.item import NWItem
from novelwriter.core.itemmodel import ProjectModel, ProjectNode from novelwriter.core.itemmodel import ProjectModel, ProjectNode
from novelwriter.dialogs.docmerge import GuiDocMerge from novelwriter.dialogs.docmerge import GuiDocMerge
@@ -594,11 +594,11 @@ class GuiProjectTree(QTreeView):
def loadModel(self) -> None: def loadModel(self) -> None:
"""Load and prepare a new project model.""" """Load and prepare a new project model."""
selModel = self.selectionModel() # selModel = self.selectionModel()
self.setModel(SHARED.project.tree.model) self.setModel(SHARED.project.tree.model)
if selModel: # if selModel:
selModel.deleteLater() # selModel.deleteLater()
del selModel # del selModel
# Lock the column sizes # Lock the column sizes
iPx = SHARED.theme.baseIconHeight iPx = SHARED.theme.baseIconHeight
@@ -814,6 +814,22 @@ class GuiProjectTree(QTreeView):
return True 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 # Events
## ##
@@ -1159,39 +1175,6 @@ class GuiProjectTree(QTreeView):
# self._scrollTimer.stop() # self._scrollTimer.stop()
return 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): class _UpdatableMenu(QMenu):
@@ -1324,7 +1307,7 @@ class _TreeContextMenu(QMenu):
self._expandCollapse() self._expandCollapse()
if isFile: if isFile:
action = self.addAction(self.tr("Duplicate")) 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() self._deleteOrTrash()
return return
+3 -11
View File
@@ -419,8 +419,8 @@ class _FilterTab(NFixedPage):
logger.debug("Building project tree") logger.debug("Building project tree")
self._treeMap = {} self._treeMap = {}
self.optTree.clear() self.optTree.clear()
for nwItem in SHARED.project.iterProjectItems(): for node in SHARED.project.tree.model.root.allChildren():
nwItem = node.item
tHandle = nwItem.itemHandle tHandle = nwItem.itemHandle
pHandle = nwItem.itemParent pHandle = nwItem.itemParent
rHandle = nwItem.itemRoot rHandle = nwItem.itemRoot
@@ -429,8 +429,6 @@ class _FilterTab(NFixedPage):
continue continue
isFile = nwItem.isFileType() isFile = nwItem.isFileType()
isActive = nwItem.isActive
if nwItem.isInactiveClass() or not self._build.isRootAllowed(rHandle): if nwItem.isInactiveClass() or not self._build.isRootAllowed(rHandle):
continue continue
@@ -439,18 +437,12 @@ class _FilterTab(NFixedPage):
nwItem.itemType, nwItem.itemClass, nwItem.itemLayout, hLevel nwItem.itemType, nwItem.itemClass, nwItem.itemLayout, hLevel
) )
if isFile:
iconName = "checked" if isActive else "unchecked"
else:
iconName = "noncheckable"
trItem = QTreeWidgetItem() trItem = QTreeWidgetItem()
trItem.setIcon(self.C_NAME, itemIcon) trItem.setIcon(self.C_NAME, itemIcon)
trItem.setText(self.C_NAME, nwItem.itemName) trItem.setText(self.C_NAME, nwItem.itemName)
trItem.setData(self.C_DATA, self.D_HANDLE, tHandle) trItem.setData(self.C_DATA, self.D_HANDLE, tHandle)
trItem.setData(self.C_DATA, self.D_FILE, isFile) 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) trItem.setTextAlignment(self.C_NAME, QtAlignLeft)
if pHandle is None and nwItem.isRootType(): if pHandle is None and nwItem.isRootType():
+4 -4
View File
@@ -181,10 +181,10 @@ def buildTestProject(obj: object, projPath: Path) -> None:
# Creating a minimal project with a few root folders and a # Creating a minimal project with a few root folders and a
# single chapter folder with a single file. # single chapter folder with a single file.
nrHandle = project.newRoot(nwItemClass.NOVEL, "Novel") nrHandle = project.newRoot(nwItemClass.NOVEL)
project.newRoot(nwItemClass.PLOT, "Plot") project.newRoot(nwItemClass.PLOT)
project.newRoot(nwItemClass.CHARACTER, "Characters") project.newRoot(nwItemClass.CHARACTER)
project.newRoot(nwItemClass.WORLD, "World") project.newRoot(nwItemClass.WORLD)
tdHandle = project.newFile("Title Page", nrHandle) tdHandle = project.newFile("Title Page", nrHandle)
cfHandle = project.newFolder("New Chapter", nrHandle) or "" cfHandle = project.newFolder("New Chapter", nrHandle) or ""