Rewrite Split Tool (#1154)

This commit is contained in:
Veronica Berglyd Olsen
2022-10-14 00:00:16 +02:00
committed by GitHub
12 changed files with 615 additions and 401 deletions
+2 -1
View File
@@ -19,7 +19,7 @@ You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
"""
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",
+122 -1
View File
@@ -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 20182022, Veronica Berglyd Olsen
@@ -25,6 +26,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
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
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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)
+118 -157
View File
@@ -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 20182022, 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("<b>{0}</b>".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}<br><br>{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
-5
View File
@@ -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):
+65 -17
View File
@@ -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
+1 -13
View File
@@ -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
+136 -4
View File
@@ -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
+51 -197
View File
@@ -19,18 +19,13 @@ You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
"""
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
-1
View File
@@ -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
+118 -3
View File
@@ -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