Re-implement project item duplication
This commit is contained in:
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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 ""
|
||||||
|
|||||||
Reference in New Issue
Block a user