diff --git a/novelwriter/core/__init__.py b/novelwriter/core/__init__.py index 90b103f8..f1b5dfd3 100644 --- a/novelwriter/core/__init__.py +++ b/novelwriter/core/__init__.py @@ -19,7 +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.doctools import DocMerger, DocSplitter from novelwriter.core.document import NWDoc from novelwriter.core.index import countWords from novelwriter.core.project import NWProject @@ -30,6 +30,7 @@ from novelwriter.core.tomd import ToMarkdown __all__ = [ "DocMerger", + "DocSplitter", "countWords", "NWDoc", "NWProject", diff --git a/novelwriter/core/doctools.py b/novelwriter/core/doctools.py index f6716590..e26f6681 100644 --- a/novelwriter/core/doctools.py +++ b/novelwriter/core/doctools.py @@ -4,7 +4,8 @@ novelWriter – Project Document Tools A collection of tools to create and manipulate documents File History: -Created: 2022-10-02 [2.0b1] +Created: 2022-10-02 [2.0b1] DocMerger +Created: 2022-10-11 [2.0b1] DocSplitter This file is a part of novelWriter Copyright 2018–2022, Veronica Berglyd Olsen @@ -25,6 +26,7 @@ along with this program. If not, see . import logging +from novelwriter.common import minmax from novelwriter.core.document import NWDoc logger = logging.getLogger(__name__) @@ -117,3 +119,122 @@ class DocMerger: return status # END Class DocMerger + + +class DocSplitter: + + def __init__(self, theProject, sHandle): + + self.theProject = theProject + + self._error = "" + self._parHandle = None + self._srcHandle = None + self._srcItem = None + + self._inFolder = False + self._rawData = [] + + srcItem = self.theProject.tree[sHandle] + if srcItem is not None and srcItem.isFileType(): + self._srcHandle = sHandle + self._srcItem = srcItem + + return + + ## + # Methods + ## + + def getError(self): + """Return any collected errors. + """ + return self._error + + def setParentItem(self, pHandle): + """Set the item that will be the top level parent item for the + new documents. + """ + self._parHandle = pHandle + self._inFolder = False + return + + def newParentFolder(self, pHandle, folderLabel): + """Create a new folder that will be the top level parent item + for the new documents. + """ + if self._srcItem is None: + return None + + newHandle = self.theProject.newFolder(folderLabel, pHandle) + newItem = self.theProject.tree[newHandle] + newItem.setStatus(self._srcItem.itemStatus) + newItem.setImport(self._srcItem.itemImport) + + self._parHandle = newHandle + self._inFolder = True + + return newHandle + + def splitDocument(self, splitData, splitText): + """Loop through the split data record and perform the split job. + """ + self._rawData = [] + buffer = splitText.copy() + for lineNo, hLevel, hLabel in reversed(splitData): + chunk = buffer[lineNo:] + buffer = buffer[:lineNo] + self._rawData.insert(0, (chunk, hLevel, hLabel)) + + return True + + def writeDocuments(self, docHierarchy): + """An iterator that will write each document in the buffer, and + return its new handle, parent handle, and sibling handle. + """ + if self._srcHandle is None: + return + + pHandle = self._parHandle + nHandle = self._parHandle if self._inFolder else self._srcHandle + hHandle = [self._parHandle, None, None, None, None] + + pLevel = 0 + for docText, hLevel, docLabel in self._rawData: + + hLevel = minmax(hLevel, 1, 4) + if pLevel == 0: + pLevel = hLevel + + if docHierarchy: + if hLevel == 1: + pHandle = self._parHandle + elif hLevel == 2: + pHandle = hHandle[1] or hHandle[0] + elif hLevel == 3: + pHandle = hHandle[2] or hHandle[1] or hHandle[0] + elif hLevel == 4: + pHandle = hHandle[3] or hHandle[2] or hHandle[1] or hHandle[0] + + if hLevel < pLevel: + nHandle = hHandle[hLevel] or hHandle[0] + elif hLevel > pLevel: + nHandle = pHandle + + dHandle = self.theProject.newFile(docLabel, pHandle) + hHandle[hLevel] = dHandle + + outDoc = NWDoc(self.theProject, dHandle) + status = outDoc.writeDocument("\n".join(docText)) + if not status: + self._error = outDoc.getError() + + yield status, dHandle, nHandle + + hHandle[hLevel] = dHandle + nHandle = dHandle + pLevel = hLevel + + return + +# END Class DocSplitter diff --git a/novelwriter/core/options.py b/novelwriter/core/options.py index d36e677b..7aa2b48a 100644 --- a/novelwriter/core/options.py +++ b/novelwriter/core/options.py @@ -42,7 +42,7 @@ VALID_MAP = { "widthCol3", "sortCol", "sortOrder", "incNovel", "incNotes", "hideZeros", "hideNegative", "groupByDay", "showIdleTime", "histMax" }, - "GuiDocSplit": {"spLevel"}, + "GuiDocSplit": {"spLevel", "intoFolder", "docHierarchy"}, "GuiBuildNovel": { "winWidth", "winHeight", "boxWidth", "docWidth", "hideScene", "hideSection", "addNovel", "addNotes", "ignoreFlag", "justifyText", diff --git a/novelwriter/dialogs/docmerge.py b/novelwriter/dialogs/docmerge.py index 7113fc0f..3dcf6d34 100644 --- a/novelwriter/dialogs/docmerge.py +++ b/novelwriter/dialogs/docmerge.py @@ -75,7 +75,7 @@ class GuiDocMerge(QDialog): # Merge Options self.trashLabel = QLabel(self.tr("Move merged items to Trash")) - self.trashSwitch = QSwitch() + self.trashSwitch = QSwitch(width=2*iPx, height=iPx) self.optBox = QGridLayout() self.optBox.addWidget(self.trashLabel, 0, 0) diff --git a/novelwriter/dialogs/docsplit.py b/novelwriter/dialogs/docsplit.py index f99166fb..f744f32e 100644 --- a/novelwriter/dialogs/docsplit.py +++ b/novelwriter/dialogs/docsplit.py @@ -1,10 +1,11 @@ """ -novelWriter – GUI Doc Split Tool -================================ -GUI class for splitting a single document into multiple documents +novelWriter – GUI Doc Split Dialog +================================== +Custom dialog class for splitting documents. File History: -Created: 2020-02-01 [0.4.3] +Created: 2020-02-01 [0.4.3] +Rewritten: 2022-10-12 [2.0b1] This file is a part of novelWriter Copyright 2018–2022, Veronica Berglyd Olsen @@ -29,19 +30,22 @@ import novelwriter from PyQt5.QtCore import Qt from PyQt5.QtWidgets import ( QDialog, QVBoxLayout, QComboBox, QListWidget, QAbstractItemView, - QListWidgetItem, QDialogButtonBox, QLabel + QListWidgetItem, QDialogButtonBox, QLabel, QGridLayout ) from novelwriter.core import NWDoc -from novelwriter.enum import nwAlert -from novelwriter.gui.custom import QHelpLabel +from novelwriter.gui.custom import QHelpLabel, QSwitch logger = logging.getLogger(__name__) class GuiDocSplit(QDialog): - def __init__(self, mainGui): + LINE_ROLE = Qt.UserRole + LEVEL_ROLE = Qt.UserRole + 1 + LABEL_ROLE = Qt.UserRole + 2 + + def __init__(self, mainGui, sHandle): super().__init__(parent=mainGui) logger.debug("Initialising GuiDocSplit ...") @@ -49,12 +53,12 @@ class GuiDocSplit(QDialog): self.mainConf = novelwriter.CONFIG self.mainGui = mainGui + self.mainTheme = mainGui.mainTheme self.theProject = mainGui.theProject - self.sourceItem = None - self.sourceText = [] + self._data = {} + self._text = [] - self.outerBox = QVBoxLayout() self.setWindowTitle(self.tr("Split Document")) self.headLabel = QLabel("{0}".format(self.tr("Document Headers"))) @@ -63,6 +67,18 @@ class GuiDocSplit(QDialog): self.mainGui.mainTheme.helpText ) + # Values + iPx = self.mainTheme.baseIconSize + hSp = self.mainConf.pxInt(12) + vSp = self.mainConf.pxInt(8) + bSp = self.mainConf.pxInt(12) + + pOptions = self.theProject.options + spLevel = pOptions.getInt("GuiDocSplit", "spLevel", 3) + intoFolder = pOptions.getBool("GuiDocSplit", "intoFolder", True) + docHierarchy = pOptions.getBool("GuiDocSplit", "docHierarchy", True) + + # Header Selection self.listBox = QListWidget() self.listBox.setDragDropMode(QAbstractItemView.NoDragDrop) self.listBox.setMinimumWidth(self.mainConf.pxInt(400)) @@ -73,206 +89,151 @@ class GuiDocSplit(QDialog): self.splitLevel.addItem(self.tr("Split up to Header Level 2 (Chapter)"), 2) self.splitLevel.addItem(self.tr("Split up to Header Level 3 (Scene)"), 3) self.splitLevel.addItem(self.tr("Split up to Header Level 4 (Section)"), 4) - spIndex = self.splitLevel.findData( - self.theProject.options.getInt("GuiDocSplit", "spLevel", 3) - ) + spIndex = self.splitLevel.findData(spLevel) if spIndex != -1: self.splitLevel.setCurrentIndex(spIndex) - self.splitLevel.currentIndexChanged.connect(self._populateList) + self.splitLevel.currentIndexChanged.connect(self._reloadList) + # Split Options + self.folderLabel = QLabel(self.tr("Split into a new folder")) + self.folderSwitch = QSwitch(width=2*iPx, height=iPx) + self.folderSwitch.setChecked(intoFolder) + + self.hierarchyLabel = QLabel(self.tr("Create document hierarchy")) + self.hierarchySwitch = QSwitch(width=2*iPx, height=iPx) + self.hierarchySwitch.setChecked(docHierarchy) + + self.optBox = QGridLayout() + self.optBox.addWidget(self.folderLabel, 0, 0) + self.optBox.addWidget(self.folderSwitch, 0, 1) + self.optBox.addWidget(self.hierarchyLabel, 1, 0) + self.optBox.addWidget(self.hierarchySwitch, 1, 1) + self.optBox.setVerticalSpacing(vSp) + self.optBox.setHorizontalSpacing(hSp) + self.optBox.setColumnStretch(2, 1) + + # Buttons self.buttonBox = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) - self.buttonBox.accepted.connect(self._doSplit) - 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.addWidget(self.splitLevel) - 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) logger.debug("GuiDocSplit initialisation complete") return - ## - # Buttons - ## - - def _doSplit(self): - """Perform the split of the file, create a new folder in the - same parent folder, and multiple files depending on split level - settings. The old file is not removed in the split process, and - must be deleted manually. + def getData(self): + """Return the user's choices. Also save the users options for + the next time the dialog is used. """ - logger.verbose("GuiDocSplit split button clicked") - - if self.sourceItem is None: - self.mainGui.makeAlert(self.tr( - "No source document selected. Nothing to do." - ), nwAlert.ERROR) - return False - - srcItem = self.theProject.tree[self.sourceItem] - if srcItem is None: - self.mainGui.makeAlert(self.tr( - "Could not parse source document." - ), nwAlert.ERROR) - return False - - inDoc = NWDoc(self.theProject, self.sourceItem) - theText = inDoc.readDocument() - - docErr = inDoc.getError() - if theText is None and docErr: - self.mainGui.makeAlert([ - self.tr("Failed to open document file."), docErr - ], nwAlert.ERROR) - - if theText is None: - theText = "" - - nLines = len(self.sourceText) - logger.debug("Splitting document %s with %d lines", self.sourceItem, nLines) - - finalOrder = [] + headerList = [] for i in range(self.listBox.count()): - listItem = self.listBox.item(i) - wTitle = listItem.text() - lineNo = listItem.data(Qt.UserRole) - finalOrder.append([wTitle, lineNo, nLines]) - if i > 0: - finalOrder[i-1][2] = lineNo - - nFiles = len(finalOrder) - if nFiles == 0: - self.mainGui.makeAlert(self.tr( - "No headers found. Nothing to do." - ), nwAlert.ERROR) - return False - - msgYes = self.mainGui.askQuestion( - self.tr("Split Document"), - "{0}

{1}".format( - self.tr( - "The document will be split into {0} file(s) in a new folder. " - "The original document will remain intact." - ).format(nFiles), - self.tr( - "Continue with the splitting process?" - ) - ) - ) - if not msgYes: - return False - - # Create the folder - fHandle = self.theProject.newFolder(srcItem.itemName, srcItem.itemParent) - self.mainGui.projView.revealNewTreeItem(fHandle) - logger.verbose("Creating folder '%s'", fHandle) - - # Loop through, and create the files - for wTitle, iStart, iEnd in finalOrder: - - wTitle = wTitle.lstrip("#").strip() - nHandle = self.theProject.newFile(wTitle, fHandle) - newItem = self.theProject.tree[nHandle] - newItem.setStatus(srcItem.itemStatus) - newItem.setImport(srcItem.itemImport) - logger.verbose( - "Creating new document '%s' with text from line %d to %d", - nHandle, iStart+1, iEnd + item = self.listBox.item(i) + headerList.append( + (item.data(self.LINE_ROLE), item.data(self.LEVEL_ROLE), item.data(self.LABEL_ROLE)) ) - theText = "\n".join(self.sourceText[iStart:iEnd]) - theText = theText.rstrip("\n") + "\n\n" + spLevel = self.splitLevel.currentData() + intoFolder = self.folderSwitch.isChecked() + docHierarchy = self.hierarchySwitch.isChecked() - 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._data["spLevel"] = spLevel + self._data["headerList"] = headerList + self._data["intoFolder"] = intoFolder + self._data["docHierarchy"] = docHierarchy - self.mainGui.projView.revealNewTreeItem(nHandle) + pOptions = self.theProject.options + pOptions.setValue("GuiDocSplit", "spLevel", spLevel) + pOptions.setValue("GuiDocSplit", "intoFolder", intoFolder) + pOptions.setValue("GuiDocSplit", "docHierarchy", docHierarchy) - self._doClose() + return self._data, self._text - return True + ## + # Slots + ## - def _doClose(self): - """Close the dialog window without doing anything. + def _reloadList(self): + """Reload the content of the list box. """ - self.theProject.options.saveSettings() - self.close() + sHandle = self._data.get("sHandle", None) + self._loadContent(sHandle) return ## # 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): + """Load content from a given source item. """ + self._data = {} + self._data["sHandle"] = sHandle + self.listBox.clear() - if self.sourceItem is None: - self.sourceItem = self.mainGui.projView.getSelectedHandle() - if self.sourceItem is None: - return False - - nwItem = self.theProject.tree[self.sourceItem] - if nwItem is None: - return False - - if not nwItem.isFileType(): - self.mainGui.makeAlert(self.tr( - "Element selected in the project tree must be a file." - ), nwAlert.ERROR) - return False - - inDoc = NWDoc(self.theProject, self.sourceItem) - theText = inDoc.readDocument() - if theText is None: - theText = "" - return False + nwItem = self.theProject.tree[sHandle] + if nwItem is None or not nwItem.isFileType(): + return spLevel = self.splitLevel.currentData() - self.theProject.options.setValue("GuiDocSplit", "spLevel", spLevel) - logger.debug( - "Scanning document '%s' for headings level <= %d", - self.sourceItem, spLevel - ) + if not self._text: + inDoc = NWDoc(self.theProject, sHandle) + self._text = (inDoc.readDocument() or "").splitlines() - self.sourceText = theText.splitlines() - for lineNo, aLine in enumerate(self.sourceText): + for lineNo, aLine in enumerate(self._text): onLine = -1 + hLevel = 0 if aLine.startswith("# ") and spLevel >= 1: onLine = lineNo + hLevel = 1 + hLabel = aLine[2:].strip() elif aLine.startswith("## ") and spLevel >= 2: onLine = lineNo + hLevel = 2 + hLabel = aLine[3:].strip() elif aLine.startswith("### ") and spLevel >= 3: onLine = lineNo + hLevel = 3 + hLabel = aLine[4:].strip() elif aLine.startswith("#### ") and spLevel >= 4: onLine = lineNo + hLevel = 4 + hLabel = aLine[5:].strip() + elif aLine.startswith("#! ") and spLevel >= 1: + onLine = lineNo + hLevel = 1 + hLabel = aLine[3:].strip() + elif aLine.startswith("##! ") and spLevel >= 2: + onLine = lineNo + hLevel = 2 + hLabel = aLine[4:].strip() - if onLine >= 0: + if onLine >= 0 and hLevel > 0: newItem = QListWidgetItem() newItem.setText(aLine.strip()) - newItem.setData(Qt.UserRole, onLine) + newItem.setData(self.LINE_ROLE, onLine) + newItem.setData(self.LEVEL_ROLE, hLevel) + newItem.setData(self.LABEL_ROLE, hLabel) self.listBox.addItem(newItem) - return True + return # END Class GuiDocSplit diff --git a/novelwriter/gui/mainmenu.py b/novelwriter/gui/mainmenu.py index f4e6e668..79361feb 100644 --- a/novelwriter/gui/mainmenu.py +++ b/novelwriter/gui/mainmenu.py @@ -244,11 +244,6 @@ class GuiMainMenu(QMenuBar): self.aImportFile.triggered.connect(lambda: self.mainGui.importDocument()) self.docuMenu.addAction(self.aImportFile) - # Document > Split Document - self.aSplitDoc = QAction(self.tr("Split Document to Folder"), self) - self.aSplitDoc.triggered.connect(lambda: self.mainGui.splitDocument()) - self.docuMenu.addAction(self.aSplitDoc) - return def _buildEditMenu(self): diff --git a/novelwriter/gui/projtree.py b/novelwriter/gui/projtree.py index 675cf65c..78609b45 100644 --- a/novelwriter/gui/projtree.py +++ b/novelwriter/gui/projtree.py @@ -40,8 +40,9 @@ from PyQt5.QtWidgets import ( ) from novelwriter.core import DocMerger +from novelwriter.core.doctools import DocSplitter from novelwriter.enum import nwDocMode, nwItemType, nwItemClass, nwItemLayout, nwAlert -from novelwriter.dialogs import GuiDocMerge, GuiEditLabel +from novelwriter.dialogs import GuiDocMerge, GuiDocSplit, GuiEditLabel from novelwriter.constants import nwHeaders, trConst, nwLabels logger = logging.getLogger(__name__) @@ -516,18 +517,14 @@ class GuiProjectTree(QTreeWidget): # Handle new file creation if itemType == nwItemType.FILE and hLevel > 0: - if self.theProject.writeNewFile(tHandle, hLevel, not isNote): - # If successful, update word count - wC = self.theProject.index.getCounts(tHandle)[1] - self.propagateCount(tHandle, wC) - self.projView.wordCountsChanged.emit() + self.theProject.writeNewFile(tHandle, hLevel, not isNote) # Add the new item to the project tree - self.revealNewTreeItem(tHandle, nHandle) + self.revealNewTreeItem(tHandle, nHandle=nHandle, wordCount=True) return True - def revealNewTreeItem(self, tHandle, nHandle=None): + def revealNewTreeItem(self, tHandle, nHandle=None, wordCount=False): """Reveal a newly added project item in the project tree. """ nwItem = self.theProject.tree[tHandle] @@ -538,6 +535,11 @@ class GuiProjectTree(QTreeWidget): if trItem is None: return False + if nwItem.isFileType() and wordCount: + wC = self.theProject.index.getCounts(tHandle)[1] + self.propagateCount(tHandle, wC) + self.projView.wordCountsChanged.emit() + pHandle = nwItem.itemParent if pHandle is not None and pHandle in self._treeMap: self._treeMap[pHandle].setExpanded(True) @@ -801,17 +803,16 @@ class GuiProjectTree(QTreeWidget): logger.debug("Permanently deleting item '%s'", tHandle) self.propagateCount(tHandle, 0) - itemList = self.getTreeFromHandle(tHandle) trItemP = trItemS.parent() tIndex = trItemP.indexOfChild(trItemS) trItemP.takeChild(tIndex) - for dHandle in reversed(itemList): + for dHandle in reversed(self.getTreeFromHandle(tHandle)): if self.mainGui.docEditor.docHandle() == dHandle: self.mainGui.closeDocument() - self.theProject.removeItem(tHandle) - self._treeMap.pop(tHandle, None) + self.theProject.removeItem(dHandle) + self._treeMap.pop(dHandle, None) self._alertTreeChange(tHandle, flush=flush) self.projView.wordCountsChanged.emit() @@ -1432,14 +1433,14 @@ class GuiProjectTree(QTreeWidget): if not docMerger.writeTargetDoc(): self.mainGui.makeAlert([ - self.tr("Could not save document."), docMerger.getError() + self.tr("Could not write document content."), docMerger.getError() ], nwAlert.ERROR) return False - if newFile: - self.mainGui.projView.revealNewTreeItem(mHandle, tHandle) - self.theProject.index.reIndexHandle(mHandle) + if newFile: + self.revealNewTreeItem(mHandle, nHandle=tHandle, wordCount=True) + self.mainGui.openDocument(mHandle, doScroll=True) if mrgData.get("moveToTrash", False): @@ -1458,7 +1459,54 @@ class GuiProjectTree(QTreeWidget): return True def _splitDocument(self, tHandle): - return + """Split a document into multiple documents. + """ + logger.info("Request to split items with handle '%s'", tHandle) + + tItem = self.theProject.tree[tHandle] + if tItem is None: + return False + + if not tItem.isFileType(): + logger.error("Only documents can be split") + return False + + dlgSplit = GuiDocSplit(self.mainGui, tHandle) + dlgSplit.exec_() + + if dlgSplit.result() == QDialog.Accepted: + + splitData, splitText = dlgSplit.getData() + + headerList = splitData.get("headerList", []) + intoFolder = splitData.get("intoFolder", False) + docHierarchy = splitData.get("docHierarchy", False) + + docSplit = DocSplitter(self.theProject, tHandle) + if intoFolder: + fHandle = docSplit.newParentFolder(tItem.itemParent, tItem.itemName) + self.revealNewTreeItem(fHandle, nHandle=tHandle) + self._alertTreeChange(fHandle, flush=False) + else: + docSplit.setParentItem(tItem.itemParent) + + docSplit.splitDocument(headerList, splitText) + for writeOk, dHandle, nHandle in docSplit.writeDocuments(docHierarchy): + self.theProject.index.reIndexHandle(dHandle) + self.revealNewTreeItem(dHandle, nHandle=nHandle, wordCount=True) + self._alertTreeChange(dHandle, flush=False) + if not writeOk: + self.mainGui.makeAlert([ + self.tr("Could not write document content."), docSplit.getError() + ], nwAlert.ERROR) + + self.saveTreeOrder() + + else: + logger.info("Action cancelled by user") + return False + + return True def _scanChildren(self, theList, tItem, tIndex): """This is a recursive function returning all items in a tree diff --git a/novelwriter/guimain.py b/novelwriter/guimain.py index 79f56b97..67071bf5 100644 --- a/novelwriter/guimain.py +++ b/novelwriter/guimain.py @@ -44,7 +44,7 @@ from novelwriter.gui import ( GuiViewsBar ) from novelwriter.dialogs import ( - GuiAbout, GuiDocSplit, GuiPreferences, GuiProjectDetails, GuiProjectLoad, + GuiAbout, GuiPreferences, GuiProjectDetails, GuiProjectLoad, GuiProjectSettings, GuiUpdates, GuiWordList ) from novelwriter.tools import ( @@ -754,18 +754,6 @@ class GuiMain(QMainWindow): return True - def splitDocument(self): - """Split a single document into multiple documents. - """ - if not self.hasProject: - logger.error("No project open") - return False - - dlgSplit = GuiDocSplit(self) - dlgSplit.exec_() - - return True - def passDocumentAction(self, theAction): """Pass on document action to the document viewer if it has focus, or pass it to the document editor if it or any of diff --git a/tests/test_core/test_core_doctools.py b/tests/test_core/test_core_doctools.py index 0d93fcb9..ab55aef4 100644 --- a/tests/test_core/test_core_doctools.py +++ b/tests/test_core/test_core_doctools.py @@ -27,8 +27,7 @@ from shutil import copyfile from mock import causeOSError from tools import C, buildTestProject, cmpFiles -from novelwriter.core.project import NWProject -from novelwriter.core.doctools import DocMerger +from novelwriter.core import NWProject, DocMerger, DocSplitter, NWDoc @pytest.mark.core @@ -39,8 +38,8 @@ def testCoreDocTools_DocMerger(monkeypatch, mockGUI, fncDir, outDir, refDir, moc mockRnd.reset() buildTestProject(theProject, fncDir) - # Create File to Merge - # ==================== + # Create Files to Merge + # ===================== hChapter1 = theProject.newFile("Chapter 1", C.hNovelRoot) hSceneOne11 = theProject.newFile("Scene 1.1", hChapter1) @@ -118,3 +117,136 @@ def testCoreDocTools_DocMerger(monkeypatch, mockGUI, fncDir, outDir, refDir, moc docMerger.writeTargetDoc() # END Test testCoreDocTools_DocMerger + + +@pytest.mark.core +def testCoreDocTools_DocSplitter(monkeypatch, mockGUI, fncDir, outDir, refDir, mockRnd, ipsumText): + """Test the DocSplitter utility. + """ + theProject = NWProject(mockGUI) + mockRnd.reset() + buildTestProject(theProject, fncDir) + + # Create File to Split + # ==================== + + hSplitDoc = theProject.newFile("Split Doc", C.hNovelRoot) + + docData = [ + "# Part One", ipsumText[0], + "## Chapter One", ipsumText[1], + "### Scene One", ipsumText[2], + "#### Section One", ipsumText[3], + "#### Section Two", ipsumText[4], + "### Scene Two", ipsumText[0], + "## Chapter Two", ipsumText[1], + "### Scene Three", ipsumText[2], + "### Scene Four", ipsumText[3], + "### Scene Five", ipsumText[4], + ] + splitData = [ + (0, 1, "Part One"), + (4, 2, "Chapter One"), + (8, 3, "Scene One"), + (12, 4, "Section One"), + (16, 4, "Section Two"), + (20, 3, "Scene Two"), + (24, 2, "Chapter Two"), + (28, 3, "Scene Three"), + (32, 3, "Scene Four"), + (36, 3, "Scene Five"), + ] + + docText = "\n\n".join(docData) + docRaw = docText.splitlines() + assert NWDoc(theProject, hSplitDoc).writeDocument(docText) is True + + docSplitter = DocSplitter(theProject, hSplitDoc) + assert docSplitter._srcItem.isFileType() + assert docSplitter.getError() == "" + + # Run the split algorithm + docSplitter.splitDocument(splitData, docRaw) + for i, (lineNo, hLevel, hLabel) in enumerate(splitData): + assert docSplitter._rawData[i] == (docRaw[lineNo:lineNo+4], hLevel, hLabel) + + # Test flat split into same parent + docSplitter.setParentItem(C.hNovelRoot) + assert docSplitter._inFolder is False + + # Cause write error on all chunks + with monkeypatch.context() as mp: + mp.setattr("builtins.open", causeOSError) + resStatus = [] + for status, _, _ in docSplitter.writeDocuments(False): + resStatus.append(status) + assert not any(resStatus) + assert docSplitter.getError() == "OSError: Mock OSError" + + # Generate as flat structure in root folder + resStatus = [] + resDocHandle = [] + resNearHandle = [] + for status, dHandle, nHandle in docSplitter.writeDocuments(False): + resStatus.append(status) + resDocHandle.append(dHandle) + resNearHandle.append(nHandle) + + assert all(resStatus) + assert resDocHandle == [ + "000000000001b", "000000000001c", "000000000001d", "000000000001e", "000000000001f", + "0000000000020", "0000000000021", "0000000000022", "0000000000023", "0000000000024", + ] + assert resNearHandle == [ # Each document should be next to the previous one + hSplitDoc, "000000000001b", "000000000001c", "000000000001d", "000000000001e", + "000000000001f", "0000000000020", "0000000000021", "0000000000022", "0000000000023", + ] + + # Generate as hierarchy in new folder + hSplitFolder = docSplitter.newParentFolder(C.hNovelRoot, "Split Folder") + assert docSplitter._inFolder is True + + resStatus = [] + resDocHandle = [] + resNearHandle = [] + for status, dHandle, nHandle in docSplitter.writeDocuments(True): + resStatus.append(status) + resDocHandle.append(dHandle) + resNearHandle.append(nHandle) + + assert all(resStatus) + assert resDocHandle == [ + "0000000000026", # Part One + "0000000000027", # Chapter One + "0000000000028", # Scene One + "0000000000029", # Section One + "000000000002a", # Section Two + "000000000002b", # Scene Two + "000000000002c", # Chapter Two + "000000000002d", # Scene Three + "000000000002e", # Scene Four + "000000000002f", # Scene Five + ] + assert resNearHandle == [ + hSplitFolder, # Part One is after Split Folder + "0000000000026", # Chapter One is after Part One + "0000000000027", # Scene One is after Chapter One + "0000000000028", # Section One is after Scene One + "0000000000029", # Section Two is after Section One + "0000000000028", # Scene Two is after Scene One + "0000000000027", # Chapter Two is after Chapter One + "000000000002c", # Scene Three is after Chapter Two + "000000000002d", # Scene Four is after Scene Three + "000000000002e", # Scene Five is after Scene Four + ] + + # Check handling of improper initialisation + docSplitter = DocSplitter(theProject, C.hInvalid) + assert docSplitter._srcHandle is None + assert docSplitter._srcItem is None + assert docSplitter.newParentFolder(C.hNovelRoot, "Split Folder") is None + assert list(docSplitter.writeDocuments(False)) == [] + + theProject.saveProject() + +# END Test testCoreDocTools_DocSplitter diff --git a/tests/test_dialogs/test_dlg_docsplit.py b/tests/test_dialogs/test_dlg_docsplit.py index d0201d6a..84b65a51 100644 --- a/tests/test_dialogs/test_dlg_docsplit.py +++ b/tests/test_dialogs/test_dlg_docsplit.py @@ -19,18 +19,13 @@ You should have received a copy of the GNU General Public License along with this program. If not, see . """ -import os import pytest -from mock import causeOSError -from tools import getGuiItem, readFile, writeFile, buildTestProject +from tools import C, buildTestProject -from PyQt5.QtWidgets import QAction, QMessageBox +from PyQt5.QtWidgets import QMessageBox -from novelwriter.enum import nwItemType, nwWidget from novelwriter.dialogs import GuiDocSplit, GuiEditLabel -from novelwriter.core.tree import NWTree -from novelwriter.core.document import NWDoc @pytest.mark.gui @@ -45,207 +40,66 @@ def testDlgSplit_Main(qtbot, monkeypatch, nwGUI, fncProj, mockRnd): # Create a new project buildTestProject(nwGUI, fncProj) - # Handles for new objects - hNovelRoot = "0000000000008" - hChapterDir = "000000000000d" - hToSplit = "0000000000010" - hNewFolder = "0000000000021" - hPartition = "0000000000022" - hChapterOne = "0000000000023" - hSceneOne = "0000000000024" - hSceneTwo = "0000000000025" - hSceneThree = "0000000000026" - hSceneFour = "0000000000027" - hSceneFive = "0000000000028" + theProject = nwGUI.theProject + projTree = nwGUI.projView.projTree - # Add Project Content - nwGUI.switchFocus(nwWidget.TREE) - nwGUI.projView.projTree.clearSelection() - nwGUI.projView.projTree._getTreeItem(hNovelRoot).setSelected(True) - nwGUI.projView.projTree.newTreeItem(nwItemType.FILE) - - assert nwGUI.saveProject() is True - assert nwGUI.closeProject() is True - - tPartition = "# Nantucket" - tChapterOne = "## Chapter One\n\n% Chapter one comment" - tSceneOne = "### Scene One\n\nThere once was a man from Nantucket" - tSceneTwo = "### Scene Two\n\nWho kept all his cash in a bucket." - tSceneThree = "### Scene Three\n\n\tBut his daughter, named Nan, \n\tRan away with a man" - tSceneFour = "### Scene Four\n\nAnd as for the bucket, Nantucket." - tSceneFive = "#### The End\n\nend" - - tToSplit = ( - f"{tPartition}\n\n{tChapterOne}\n\n" - f"{tSceneOne}\n\n{tSceneTwo}\n\n" - f"{tSceneThree}\n\n{tSceneFour}\n\n" - f"{tSceneFive}\n\n" + docText = ( + "Text\n\n" + "##! Prologue\n\nText\n\n" + "## Chapter One\n\nText\n\n" + "### Scene One\n\nText\n\n" + "### Scene Two\n\nText\n\n" + "## Chapter Two\n\nText\n\n" + "### Scene Three\n\nText\n\n" + "### Scene Four\n\nText\n\n" + "#! New Title\n\nText\n\n" + "## New Chapter\n\nText\n\n" + "### New Scene\n\nText\n\n" + "#### New Section\n\nText\n\n" ) - contentDir = os.path.join(fncProj, "content") - writeFile(os.path.join(contentDir, hToSplit+".nwd"), tToSplit) + hSplitDoc = theProject.newFile("Split Doc", C.hNovelRoot) + theProject.writeNewFile(hSplitDoc, 1, True, docText) + projTree.revealNewTreeItem(hSplitDoc, nHandle=C.hNovelRoot, wordCount=True) - assert nwGUI.openProject(fncProj) is True + docText = f"# Split Doc\n\n{docText}" - # Open the Split tool - nwGUI.switchFocus(nwWidget.TREE) - nwGUI.projView.projTree.clearSelection() - nwGUI.projView.projTree._getTreeItem(hToSplit).setSelected(True) - - monkeypatch.setattr(GuiDocSplit, "exec_", lambda *a: None) - nwGUI.mainMenu.aSplitDoc.activate(QAction.Trigger) - qtbot.waitUntil(lambda: getGuiItem("GuiDocSplit") is not None, timeout=1000) - - nwSplit = getGuiItem("GuiDocSplit") - assert isinstance(nwSplit, GuiDocSplit) + nwSplit = GuiDocSplit(nwGUI, hSplitDoc) nwSplit.show() - qtbot.wait(50) + qtbot.addWidget(nwSplit) - # Populate List - # ============= + # By default, only up to level three headinsg should be listed + assert nwSplit.splitLevel.currentData() == 3 + assert nwSplit.listBox.count() == 11 - nwSplit.listBox.clear() - assert nwSplit.listBox.count() == 0 - - # No item selected - nwSplit.sourceItem = None - nwGUI.projView.projTree.clearSelection() - assert nwSplit._populateList() is False - assert nwSplit.listBox.count() == 0 - - # Non-existing item - with monkeypatch.context() as mp: - mp.setattr(NWTree, "__getitem__", lambda *a: None) - nwSplit.sourceItem = None - nwGUI.projView.projTree.clearSelection() - nwGUI.projView.projTree._getTreeItem(hToSplit).setSelected(True) - assert nwSplit._populateList() is False - assert nwSplit.listBox.count() == 0 - - # Select a non-file - nwSplit.sourceItem = None - nwGUI.projView.projTree.clearSelection() - nwGUI.projView.projTree._getTreeItem(hChapterDir).setSelected(True) - assert nwSplit._populateList() is False - assert nwSplit.listBox.count() == 0 - - # Error when reading documents - with monkeypatch.context() as mp: - mp.setattr(NWDoc, "readDocument", lambda *a: None) - nwSplit.sourceItem = hToSplit - assert nwSplit._populateList() is False - assert nwSplit.listBox.count() == 0 - - # Read properly, and check split levels - - # Level 1 - nwSplit.splitLevel.setCurrentIndex(0) - nwSplit.sourceItem = hToSplit - assert nwSplit._populateList() is True - assert nwSplit.listBox.count() == 1 - - # Level 2 - nwSplit.splitLevel.setCurrentIndex(1) - nwSplit.sourceItem = hToSplit - assert nwSplit._populateList() is True - assert nwSplit.listBox.count() == 2 - - # Level 3 - nwSplit.splitLevel.setCurrentIndex(2) - nwSplit.sourceItem = hToSplit - assert nwSplit._populateList() is True - assert nwSplit.listBox.count() == 6 - - # Level 4 + # Changing to level 4, should reload and add the last section nwSplit.splitLevel.setCurrentIndex(3) - nwSplit.sourceItem = hToSplit - assert nwSplit._populateList() is True - assert nwSplit.listBox.count() == 7 + assert nwSplit.listBox.count() == 12 - # Split Document - # ============== + data, text = nwSplit.getData() + assert text == docText.splitlines() + assert data["sHandle"] == hSplitDoc + assert data["spLevel"] == 4 + assert data["intoFolder"] is True + assert data["docHierarchy"] is True + assert data["headerList"][0] == (0, 1, "Split Doc") + assert data["headerList"][1] == (4, 2, "Prologue") + assert data["headerList"][2] == (8, 2, "Chapter One") + assert data["headerList"][3] == (12, 3, "Scene One") + assert data["headerList"][4] == (16, 3, "Scene Two") + assert data["headerList"][5] == (20, 2, "Chapter Two") + assert data["headerList"][6] == (24, 3, "Scene Three") + assert data["headerList"][7] == (28, 3, "Scene Four") + assert data["headerList"][8] == (32, 1, "New Title") + assert data["headerList"][9] == (36, 2, "New Chapter") + assert data["headerList"][10] == (40, 3, "New Scene") + assert data["headerList"][11] == (44, 4, "New Section") - # Test a proper split first - with monkeypatch.context() as mp: - mp.setattr(GuiDocSplit, "_doClose", lambda *a: None) - assert nwSplit._doSplit() is True - assert nwGUI.saveProject() + # Loading the dialog on a non-file item produces an empty list + nwSplit._loadContent(C.hNovelRoot) + assert nwSplit.listBox.count() == 0 - assert readFile(os.path.join(contentDir, hPartition+".nwd")) == ( - "%%%%~name: Nantucket\n" - "%%%%~path: %s/%s\n" - "%%%%~kind: NOVEL/DOCUMENT\n" - "%s\n\n" - ) % (hNewFolder, hPartition, tPartition) - - assert readFile(os.path.join(contentDir, hChapterOne+".nwd")) == ( - "%%%%~name: Chapter One\n" - "%%%%~path: %s/%s\n" - "%%%%~kind: NOVEL/DOCUMENT\n" - "%s\n\n" - ) % (hNewFolder, hChapterOne, tChapterOne) - - assert readFile(os.path.join(contentDir, hSceneOne+".nwd")) == ( - "%%%%~name: Scene One\n" - "%%%%~path: %s/%s\n" - "%%%%~kind: NOVEL/DOCUMENT\n" - "%s\n\n" - ) % (hNewFolder, hSceneOne, tSceneOne) - - assert readFile(os.path.join(contentDir, hSceneTwo+".nwd")) == ( - "%%%%~name: Scene Two\n" - "%%%%~path: %s/%s\n" - "%%%%~kind: NOVEL/DOCUMENT\n" - "%s\n\n" - ) % (hNewFolder, hSceneTwo, tSceneTwo) - - assert readFile(os.path.join(contentDir, hSceneThree+".nwd")) == ( - "%%%%~name: Scene Three\n" - "%%%%~path: %s/%s\n" - "%%%%~kind: NOVEL/DOCUMENT\n" - "%s\n\n" - ) % (hNewFolder, hSceneThree, tSceneThree) - - assert readFile(os.path.join(contentDir, hSceneFour+".nwd")) == ( - "%%%%~name: Scene Four\n" - "%%%%~path: %s/%s\n" - "%%%%~kind: NOVEL/DOCUMENT\n" - "%s\n\n" - ) % (hNewFolder, hSceneFour, tSceneFour) - - assert readFile(os.path.join(contentDir, hSceneFive+".nwd")) == ( - "%%%%~name: The End\n" - "%%%%~path: %s/%s\n" - "%%%%~kind: NOVEL/DOCUMENT\n" - "%s\n\n" - ) % (hNewFolder, hSceneFive, tSceneFive) - - # OS error - with monkeypatch.context() as mp: - mp.setattr("builtins.open", causeOSError) - assert nwSplit._doSplit() is False - - # Select to not split - with monkeypatch.context() as mp: - mp.setattr(QMessageBox, "question", lambda *a: QMessageBox.No) - assert nwSplit._doSplit() is False - - # Clear the list - nwSplit.listBox.clear() - assert nwSplit._doSplit() is False - - # Can't find sourcv item - with monkeypatch.context() as mp: - mp.setattr(NWTree, "__getitem__", lambda *a: None) - assert nwSplit._doSplit() is False - - # No source item set - nwSplit.sourceItem = None - assert nwSplit._doSplit() is False - - # Close - nwSplit._doClose() - - # qtbot.stopForInteraction() + nwSplit.reject() + # qtbot.stop() # END Test testDlgSplit_Main diff --git a/tests/test_gui/test_gui_guimain.py b/tests/test_gui/test_gui_guimain.py index 0a569e21..52936986 100644 --- a/tests/test_gui/test_gui_guimain.py +++ b/tests/test_gui/test_gui_guimain.py @@ -54,7 +54,6 @@ def testGuiMain_ProjectBlocker(monkeypatch, nwGUI): assert nwGUI.saveDocument() is False assert nwGUI.viewDocument(None) is False assert nwGUI.importDocument() is False - assert nwGUI.splitDocument() is False assert nwGUI.openSelectedItem() is False assert nwGUI.editItemLabel() is False assert nwGUI.requestNovelTreeRefresh() is False diff --git a/tests/test_gui/test_gui_projtree.py b/tests/test_gui/test_gui_projtree.py index 8e683742..82119f41 100644 --- a/tests/test_gui/test_gui_projtree.py +++ b/tests/test_gui/test_gui_projtree.py @@ -30,7 +30,7 @@ from PyQt5.QtWidgets import QMessageBox, QMenu, QTreeWidgetItem, QDialog from novelwriter.enum import nwItemLayout, nwItemType, nwItemClass from novelwriter.core import NWDoc -from novelwriter.dialogs import GuiEditLabel, GuiDocMerge +from novelwriter.dialogs import GuiEditLabel, GuiDocMerge, GuiDocSplit from novelwriter.gui.projtree import GuiProjectTree @@ -675,7 +675,7 @@ def testGuiProjTree_ContextMenu(qtbot, monkeypatch, nwGUI, fncDir, mockRnd): @pytest.mark.gui -def testGuiProjTree_MergeDocument(qtbot, monkeypatch, nwGUI, fncDir, mockRnd, ipsumText): +def testGuiProjTree_MergeDocuments(qtbot, monkeypatch, nwGUI, fncDir, mockRnd, ipsumText): """Test the merge document function. """ mergeData = {} @@ -780,7 +780,122 @@ def testGuiProjTree_MergeDocument(qtbot, monkeypatch, nwGUI, fncDir, mockRnd, ip # qtbot.stop() -# END Test testGuiProjTree_MergeDocument +# END Test testGuiProjTree_MergeDocuments + + +@pytest.mark.gui +def testGuiProjTree_SplitDocument(qtbot, monkeypatch, nwGUI, fncDir, mockRnd, ipsumText): + """Test the split document function. + """ + splitData = {} + splitText = [] + + # Block message box + monkeypatch.setattr(QMessageBox, "warning", lambda *a: QMessageBox.Yes) + monkeypatch.setattr(QMessageBox, "critical", lambda *a: QMessageBox.Yes) + monkeypatch.setattr(QMessageBox, "question", lambda *a: QMessageBox.Yes) + monkeypatch.setattr(QMessageBox, "information", lambda *a: QMessageBox.Yes) + + monkeypatch.setattr(GuiDocSplit, "__init__", lambda *a: None) + monkeypatch.setattr(GuiDocSplit, "exec_", lambda *a: None) + monkeypatch.setattr(GuiDocSplit, "result", lambda *a: QDialog.Accepted) + monkeypatch.setattr(GuiDocSplit, "getData", lambda *a: (splitData, splitText)) + + # Create a project + prjDir = os.path.join(fncDir, "project") + buildTestProject(nwGUI, prjDir) + + theProject = nwGUI.theProject + projTree = nwGUI.projView.projTree + + docText = ( + "Text\n\n" + "##! Prologue\n\nText\n\n" + "## Chapter One\n\nText\n\n" + "### Scene One\n\nText\n\n" + "### Scene Two\n\nText\n\n" + "## Chapter Two\n\nText\n\n" + "### Scene Three\n\nText\n\n" + "### Scene Four\n\nText\n\n" + "#! New Title\n\nText\n\n" + "## New Chapter\n\nText\n\n" + "### New Scene\n\nText\n\n" + "#### New Section\n\nText\n\n" + ) + + hSplitDoc = theProject.newFile("Split Doc", C.hNovelRoot) + theProject.writeNewFile(hSplitDoc, 1, True, docText) + projTree.revealNewTreeItem(hSplitDoc, nHandle=C.hNovelRoot, wordCount=True) + + docText = f"# Split Doc\n\n{docText}" + splitData["headerList"] = [ + (0, 1, "Split Doc"), + (4, 2, "Prologue"), + (8, 2, "Chapter One"), + (12, 3, "Scene One"), + (16, 3, "Scene Two"), + (20, 2, "Chapter Two"), + (24, 3, "Scene Three"), + (28, 3, "Scene Four"), + (32, 1, "New Title"), + (36, 2, "New Chapter"), + (40, 3, "New Scene"), + (44, 4, "New Section"), + ] + + fstSet = [ + "0000000000011", "0000000000012", "0000000000013", "0000000000014", + "0000000000015", "0000000000016", "0000000000017", "0000000000018", + "0000000000019", "000000000001a", "000000000001b", "000000000001c", + ] + sndSet = [ + "000000000001d", "000000000001e", "000000000001f", "0000000000020", + "0000000000021", "0000000000022", "0000000000023", "0000000000024", + "0000000000025", "0000000000026", "0000000000027", "0000000000028", + ] + trdSet = [ + "000000000002a", "000000000002b", "000000000002c", "000000000002d", + "000000000002e", "000000000002f", "0000000000030", "0000000000031", + "0000000000032", "0000000000033", "0000000000034", "0000000000035", + ] + + # Try to split an invalid document and a non-document + assert projTree._splitDocument(C.hInvalid) is False + assert projTree._splitDocument(C.hNovelRoot) is False + + # Split into same root folder + splitData["intoFolder"] = False + + # Writing fails + with monkeypatch.context() as mp: + mp.setattr("builtins.open", causeOSError) + assert projTree._splitDocument(hSplitDoc) is True + for tHandle in fstSet: + assert tHandle in theProject.tree + assert not os.path.isfile(os.path.join(prjDir, "content", f"{tHandle}.nwd")) + + # Writing succeeds + assert projTree._splitDocument(hSplitDoc) is True + for tHandle in sndSet: + assert tHandle in theProject.tree + assert os.path.isfile(os.path.join(prjDir, "content", f"{tHandle}.nwd")) + + # Add to a folder + splitData["intoFolder"] = True + assert projTree._splitDocument(hSplitDoc) is True + assert "0000000000029" in theProject.tree # The folder + for tHandle in trdSet: + assert tHandle in theProject.tree + assert os.path.isfile(os.path.join(prjDir, "content", f"{tHandle}.nwd")) + + # Cancelled by user + with monkeypatch.context() as mp: + mp.setattr(GuiDocSplit, "result", lambda *a: QDialog.Rejected) + assert projTree._splitDocument(hSplitDoc) is False + + # qtbot.stop() + +# END Test testGuiProjTree_SplitDocument @pytest.mark.gui