From e1c3a12d07a28053ac7222a0e5f0a8d6f960e354 Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Sun, 9 Oct 2022 15:30:02 +0200 Subject: [PATCH] Redesign the Document Merge dialog box --- novelwriter/core/__init__.py | 2 + novelwriter/core/doctools.py | 102 +++++++++++++++++ novelwriter/dialogs/docmerge.py | 189 +++++++++++++------------------- novelwriter/gui/custom.py | 14 +-- novelwriter/gui/projtree.py | 62 +++++++++-- 5 files changed, 238 insertions(+), 131 deletions(-) create mode 100644 novelwriter/core/doctools.py diff --git a/novelwriter/core/__init__.py b/novelwriter/core/__init__.py index c91ca941..90b103f8 100644 --- a/novelwriter/core/__init__.py +++ b/novelwriter/core/__init__.py @@ -19,6 +19,7 @@ You should have received a copy of the GNU General Public License along with this program. If not, see . """ +from novelwriter.core.doctools import DocMerger from novelwriter.core.document import NWDoc from novelwriter.core.index import countWords from novelwriter.core.project import NWProject @@ -28,6 +29,7 @@ from novelwriter.core.toodt import ToOdt from novelwriter.core.tomd import ToMarkdown __all__ = [ + "DocMerger", "countWords", "NWDoc", "NWProject", diff --git a/novelwriter/core/doctools.py b/novelwriter/core/doctools.py new file mode 100644 index 00000000..b85d1c19 --- /dev/null +++ b/novelwriter/core/doctools.py @@ -0,0 +1,102 @@ +""" +novelWriter – Project Document Tools +==================================== +A collection of tools to create and manipulate documents + +File History: +Created: 2022-10-02 [2.0b1] + +This file is a part of novelWriter +Copyright 2018–2022, 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 . +""" + +import logging + +logger = logging.getLogger(__name__) + +# logger.verbose("GuiDocMerge merge button clicked") + +# finalOrder = [] +# for i in range(self.listBox.count()): +# finalOrder.append(self.listBox.item(i).data(Qt.UserRole)) + +# if len(finalOrder) == 0: +# self.mainGui.makeAlert(self.tr( +# "No source documents found. Nothing to do." +# ), nwAlert.ERROR) +# return False + +# theText = "" +# for tHandle in finalOrder: +# inDoc = NWDoc(self.theProject, tHandle) +# docText = inDoc.readDocument() +# docErr = inDoc.getError() +# if docText is None and docErr: +# self.mainGui.makeAlert([ +# self.tr("Failed to open document file."), docErr +# ], nwAlert.ERROR) +# if docText: +# theText += docText.rstrip("\n")+"\n\n" + +# if self.sourceItem is None: +# self.mainGui.makeAlert(self.tr( +# "No source folder selected. Nothing to do." +# ), nwAlert.ERROR) +# return False + +# srcItem = self.theProject.tree[self.sourceItem] +# if srcItem is None: +# self.mainGui.makeAlert(self.tr("Internal error."), nwAlert.ERROR) +# return False + +# nHandle = self.theProject.newFile(srcItem.itemName, srcItem.itemParent) +# newItem = self.theProject.tree[nHandle] +# newItem.setStatus(srcItem.itemStatus) +# newItem.setImport(srcItem.itemImport) + +# outDoc = NWDoc(self.theProject, nHandle) +# if not outDoc.writeDocument(theText): +# self.mainGui.makeAlert([ +# self.tr("Could not save document."), outDoc.getError() +# ], nwAlert.ERROR) +# return False + +# self.mainGui.projView.revealNewTreeItem(nHandle) +# self.mainGui.openDocument(nHandle, doScroll=True) + +# self._doClose() + + +class DocMerger: + + def __init__(self, theProject): + + self.theProject = theProject + + self._targetDoc = None + + return + + def setTargetDoc(self, tHandle): + return + + def createNewDoc(self, docLabel, pHandle, itemLayout): + return + + def appendDoc(self, tHandle): + return + +# END Class DocMerger diff --git a/novelwriter/dialogs/docmerge.py b/novelwriter/dialogs/docmerge.py index 22a6eb6b..2d248200 100644 --- a/novelwriter/dialogs/docmerge.py +++ b/novelwriter/dialogs/docmerge.py @@ -1,10 +1,11 @@ """ -novelWriter – GUI Doc Merge Tool -================================ -GUI class for merging multiple documents to one document +novelWriter – GUI Doc Merge Dialog +================================== +Custom dialog class for merging documents. File History: -Created: 2020-01-23 [0.4.3] +Created: 2020-01-23 [0.4.3] +Rewritten: 2022-10-06 [2.0b1] This file is a part of novelWriter Copyright 2018–2022, Veronica Berglyd Olsen @@ -26,169 +27,131 @@ along with this program. If not, see . import logging import novelwriter -from PyQt5.QtCore import Qt +from PyQt5.QtCore import Qt, QSize from PyQt5.QtWidgets import ( QDialog, QVBoxLayout, QLabel, QListWidget, QAbstractItemView, - QListWidgetItem, QDialogButtonBox + QListWidgetItem, QDialogButtonBox, QGridLayout ) -from novelwriter.core import NWDoc -from novelwriter.enum import nwAlert, nwItemType -from novelwriter.gui.custom import QHelpLabel +from novelwriter.gui.custom import QHelpLabel, QSwitch logger = logging.getLogger(__name__) class GuiDocMerge(QDialog): - def __init__(self, mainGui): - QDialog.__init__(self, mainGui) + def __init__(self, mainGui, sHandle, itemList): + super().__init__(parent=mainGui) logger.debug("Initialising GuiDocMerge ...") self.setObjectName("GuiDocMerge") self.mainConf = novelwriter.CONFIG self.mainGui = mainGui + self.mainTheme = mainGui.mainTheme self.theProject = mainGui.theProject - self.sourceItem = None - self.outerBox = QVBoxLayout() + self._data = {} + self.setWindowTitle(self.tr("Merge Documents")) self.headLabel = QLabel("{0}".format(self.tr("Documents to Merge"))) - self.helpLabel = QHelpLabel( - self.tr("Drag and drop items to change the order."), self.mainGui.mainTheme.helpText - ) + self.helpLabel = QHelpLabel(self.tr( + "Drag and drop items to change the order, or uncheck to exclude." + ), self.mainTheme.helpText) + + iPx = self.mainTheme.baseIconSize + hSp = self.mainConf.pxInt(12) + vSp = self.mainConf.pxInt(8) + bSp = self.mainConf.pxInt(12) self.listBox = QListWidget() - self.listBox.setDragDropMode(QAbstractItemView.InternalMove) + self.listBox.setIconSize(QSize(iPx, iPx)) self.listBox.setMinimumWidth(self.mainConf.pxInt(400)) self.listBox.setMinimumHeight(self.mainConf.pxInt(180)) + self.listBox.setSelectionBehavior(QAbstractItemView.SelectRows) + self.listBox.setSelectionMode(QAbstractItemView.SingleSelection) + self.listBox.setDragDropMode(QAbstractItemView.InternalMove) + # Merge Options + self.trashLabel = QLabel(self.tr("Move merged items to Trash")) + self.trashSwitch = QSwitch() + + self.optBox = QGridLayout() + self.optBox.addWidget(self.trashLabel, 0, 0) + self.optBox.addWidget(self.trashSwitch, 0, 1) + self.optBox.setHorizontalSpacing(hSp) + self.optBox.setColumnStretch(2, 1) + + # Buttons self.buttonBox = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) - self.buttonBox.accepted.connect(self._doMerge) - self.buttonBox.rejected.connect(self._doClose) + self.buttonBox.accepted.connect(self.accept) + self.buttonBox.rejected.connect(self.reject) + # Assemble + self.outerBox = QVBoxLayout() self.outerBox.setSpacing(0) self.outerBox.addWidget(self.headLabel) self.outerBox.addWidget(self.helpLabel) - self.outerBox.addSpacing(self.mainConf.pxInt(8)) + self.outerBox.addSpacing(vSp) self.outerBox.addWidget(self.listBox) - self.outerBox.addSpacing(self.mainConf.pxInt(12)) + self.outerBox.addSpacing(vSp) + self.outerBox.addLayout(self.optBox) + self.outerBox.addSpacing(bSp) self.outerBox.addWidget(self.buttonBox) self.setLayout(self.outerBox) - self.rejected.connect(self._doClose) - - self._populateList() + # Load Content + self._loadContent(sHandle, itemList) logger.debug("GuiDocMerge initialisation complete") return - ## - # Buttons - ## - - def _doMerge(self): - """Perform the merge of the files in the selected folder, and - create a new file in the same parent folder. The old files are - not removed in the merge process, and must be deleted manually. + def getData(self): + """Return the user's choices. """ - logger.verbose("GuiDocMerge merge button clicked") - - finalOrder = [] + finalItems = [] for i in range(self.listBox.count()): - finalOrder.append(self.listBox.item(i).data(Qt.UserRole)) + item = self.listBox.item(i) + if item.checkState() == Qt.Checked: + finalItems.append(item.data(Qt.UserRole)) - if len(finalOrder) == 0: - self.mainGui.makeAlert(self.tr( - "No source documents found. Nothing to do." - ), nwAlert.ERROR) - return False + self._data["moveToTrash"] = self.trashSwitch.isChecked() + self._data["finalItems"] = finalItems - theText = "" - for tHandle in finalOrder: - inDoc = NWDoc(self.theProject, tHandle) - docText = inDoc.readDocument() - docErr = inDoc.getError() - if docText is None and docErr: - self.mainGui.makeAlert([ - self.tr("Failed to open document file."), docErr - ], nwAlert.ERROR) - if docText: - theText += docText.rstrip("\n")+"\n\n" - - if self.sourceItem is None: - self.mainGui.makeAlert(self.tr( - "No source folder selected. Nothing to do." - ), nwAlert.ERROR) - return False - - srcItem = self.theProject.tree[self.sourceItem] - if srcItem is None: - self.mainGui.makeAlert(self.tr("Internal error."), nwAlert.ERROR) - return False - - nHandle = self.theProject.newFile(srcItem.itemName, srcItem.itemParent) - newItem = self.theProject.tree[nHandle] - newItem.setStatus(srcItem.itemStatus) - newItem.setImport(srcItem.itemImport) - - outDoc = NWDoc(self.theProject, nHandle) - if not outDoc.writeDocument(theText): - self.mainGui.makeAlert([ - self.tr("Could not save document."), outDoc.getError() - ], nwAlert.ERROR) - return False - - self.mainGui.projView.revealNewTreeItem(nHandle) - self.mainGui.openDocument(nHandle, doScroll=True) - - self._doClose() - - return True - - def _doClose(self): - """Close the dialog window without doing anything. - """ - self.close() - return + return self._data ## # Internal Functions ## - def _populateList(self): - """Get the item selected in the tree, check that it is a folder, - and try to find all files associated with it. The valid files - are then added to the list view in order. The list itself can be - reordered by the user. + def _loadContent(self, sHandle, itemList): + """Load content from a given list of items. """ - tHandle = self.mainGui.projView.getSelectedHandle() - self.sourceItem = tHandle - if tHandle is None: - return False + self._data = {} + self._data["sHandle"] = sHandle + self._data["origItems"] = itemList - nwItem = self.theProject.tree[tHandle] - if nwItem is None: - return False - - if nwItem.itemType is not nwItemType.FOLDER: - self.mainGui.makeAlert(self.tr( - "Element selected in the project tree must be a folder." - ), nwAlert.ERROR) - return False - - for sHandle in self.mainGui.projView.getTreeFromHandle(tHandle): - newItem = QListWidgetItem() - nwItem = self.theProject.tree[sHandle] - if not nwItem.isFileType(): + self.listBox.clear() + for tHandle in itemList: + nwItem = self.theProject.tree[tHandle] + if nwItem is None or not nwItem.isFileType(): continue + + hLevel = self.theProject.index.getHandleHeaderLevel(tHandle) + itemIcon = self.mainTheme.getItemIcon( + nwItem.itemType, nwItem.itemClass, nwItem.itemLayout, hLevel + ) + + newItem = QListWidgetItem() + newItem.setIcon(itemIcon) newItem.setText(nwItem.itemName) - newItem.setData(Qt.UserRole, sHandle) + newItem.setData(Qt.UserRole, tHandle) + newItem.setCheckState(Qt.Checked) + self.listBox.addItem(newItem) - return True + return # END Class GuiDocMerge diff --git a/novelwriter/gui/custom.py b/novelwriter/gui/custom.py index e571a5a1..2949ff6a 100644 --- a/novelwriter/gui/custom.py +++ b/novelwriter/gui/custom.py @@ -202,7 +202,7 @@ class QConfigLayout(QGridLayout): class QHelpLabel(QLabel): def __init__(self, theText, textCol, fontSize=0.9): - QLabel.__init__(self, theText) + super().__init__(theText) if isinstance(textCol, QColor): qCol = textCol @@ -377,7 +377,7 @@ class QSwitch(QAbstractButton): class PagedDialog(QDialog): def __init__(self, parent=None): - QDialog.__init__(self, parent=parent) + super().__init__(parent=parent) self._tabBar = VerticalTabBar(self) self._tabBar.setExpanding(False) @@ -410,13 +410,13 @@ class PagedDialog(QDialog): return def addTab(self, widget, label): - """Forwards the adding of tabs to the QTabWidget. + """Forward the adding of tabs to the QTabWidget. """ self._tabBox.addTab(widget, label) return def addControls(self, buttonBar): - """Adds a button bar to the dialog. + """Add a button bar to the dialog. """ self._buttonBox.addWidget(buttonBar) return @@ -427,14 +427,14 @@ class PagedDialog(QDialog): class VerticalTabBar(QTabBar): def __init__(self, parent=None): - QTabBar.__init__(self, parent=parent) + super().__init__(parent=parent) self._mW = novelwriter.CONFIG.pxInt(150) return def tabSizeHint(self, index): - """Returns a transposed size hint for the rotated bar. + """Return a transposed size hint for the rotated bar. """ - tSize = QTabBar.tabSizeHint(self, index) + tSize = super().tabSizeHint(index) tSize.transpose() tSize.setWidth(min(tSize.width(), self._mW)) return tSize diff --git a/novelwriter/gui/projtree.py b/novelwriter/gui/projtree.py index e8fa541d..e841375a 100644 --- a/novelwriter/gui/projtree.py +++ b/novelwriter/gui/projtree.py @@ -34,15 +34,15 @@ from time import time from PyQt5.QtGui import QPalette from PyQt5.QtCore import Qt, QSize, pyqtSignal, pyqtSlot from PyQt5.QtWidgets import ( - QAbstractItemView, QFrame, QHBoxLayout, QHeaderView, QLabel, + QAbstractItemView, QFrame, QHBoxLayout, QHeaderView, QLabel, QDialog, QMenu, QShortcut, QSizePolicy, QToolButton, QTreeWidget, QTreeWidgetItem, QVBoxLayout, QWidget ) from novelwriter.core import NWDoc from novelwriter.enum import nwDocMode, nwItemType, nwItemClass, nwItemLayout, nwAlert +from novelwriter.dialogs import GuiDocMerge, GuiEditLabel from novelwriter.constants import nwHeaders, trConst, nwLabels -from novelwriter.dialogs.editlabel import GuiEditLabel logger = logging.getLogger(__name__) @@ -334,7 +334,7 @@ class GuiProjectTree(QTreeWidget): self.mainConf = novelwriter.CONFIG self.projView = projView self.mainGui = projView.mainGui - self.mainTheme = projView.mainGui.mainTheme + self.mainTheme = projView.mainGui.mainTheme self.theProject = projView.mainGui.theProject # Internal Variables @@ -382,9 +382,8 @@ class GuiProjectTree(QTreeWidget): trRoot = self.invisibleRootItem() trRoot.setFlags(trRoot.flags() ^ Qt.ItemIsDropEnabled) - # Set Multiple Selection by CTRL - # Disabled for now, until the merge files option has been added - # self.setSelectionMode(QAbstractItemView.ExtendedSelection) + # Set selection options + self.setSelectionMode(QAbstractItemView.SingleSelection) self.setSelectionBehavior(QAbstractItemView.SelectRows) # Connect signals @@ -1077,16 +1076,21 @@ class GuiProjectTree(QTreeWidget): lambda n, key=key: self._changeItemImport(tHandle, key) ) + # Transform Item + # ============== + + mTrans = ctxMenu.addMenu(self.tr("Transform")) + if isFile and tItem.documentAllowed(): if tItem.isNoteLayout(): - ctxMenu.addAction( + mTrans.addAction( self.tr("Convert to {0}").format( trConst(nwLabels.LAYOUT_NAME[nwItemLayout.DOCUMENT]) ), lambda: self._changeItemLayout(tHandle, nwItemLayout.DOCUMENT) ) else: - ctxMenu.addAction( + mTrans.addAction( self.tr("Convert to {0}").format( trConst(nwLabels.LAYOUT_NAME[nwItemLayout.NOTE]) ), @@ -1094,19 +1098,37 @@ class GuiProjectTree(QTreeWidget): ) elif isFolder: if tItem.documentAllowed(): - ctxMenu.addAction( + mTrans.addAction( self.tr("Convert to {0}").format( trConst(nwLabels.LAYOUT_NAME[nwItemLayout.DOCUMENT]) ), lambda: self._covertFolderToFile(tHandle, nwItemLayout.DOCUMENT) ) - ctxMenu.addAction( + mTrans.addAction( self.tr("Convert to {0}").format( trConst(nwLabels.LAYOUT_NAME[nwItemLayout.NOTE]) ), lambda: self._covertFolderToFile(tHandle, nwItemLayout.NOTE) ) + if hasChild: + if isFile: + mTrans.addAction( + self.tr("Merge Child Documents"), + lambda: self._mergeDocuments(tHandle, isFile) + ) + else: + mTrans.addAction( + self.tr("Combine Documents in Folder"), + lambda: self._mergeDocuments(tHandle, isFile) + ) + + if isFile: + mTrans.addAction( + self.tr("Split Document by Header"), + lambda: self._splitDocument(tHandle) + ) + ctxMenu.addSeparator() # Expand/Collapse @@ -1355,6 +1377,24 @@ class GuiProjectTree(QTreeWidget): logger.info("Folder conversion cancelled") return + def _mergeDocuments(self, tHandle, isFile): + """Merge an item's child documents into a single document. + """ + logger.info("Request to merge items under handle '%s'", tHandle) + itemList = self.getTreeFromHandle(tHandle) + itemList.remove(tHandle) + + dlgMerge = GuiDocMerge(self.mainGui, tHandle, itemList) + dlgMerge.exec_() + + if dlgMerge.result() == QDialog.Accepted: + print(dlgMerge.getData()) + + return + + def _splitDocument(self, tHandle): + return + def _scanChildren(self, theList, tItem, tIndex): """This is a recursive function returning all items in a tree starting at a given QTreeWidgetItem. @@ -1380,7 +1420,7 @@ class GuiProjectTree(QTreeWidget): """ tHandle = nwItem.itemHandle pHandle = nwItem.itemParent - newItem = QTreeWidgetItem([""]*4) + newItem = QTreeWidgetItem() newItem.setText(self.C_NAME, "") newItem.setText(self.C_COUNT, "0")