From 9bc66128a7e3d6f4921185164b4b53a89c1c5d4a Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Mon, 5 Jun 2023 23:39:28 +0200 Subject: [PATCH] Complete the build process in the build tool --- novelwriter/constants.py | 16 ++-- novelwriter/core/buildsettings.py | 8 +- novelwriter/core/docbuild.py | 111 +++++++++++++++++++------- novelwriter/core/index.py | 3 +- novelwriter/tools/manusbuild.py | 109 +++++++++++-------------- tests/test_core/test_core_docbuild.py | 21 +++-- 6 files changed, 156 insertions(+), 112 deletions(-) diff --git a/novelwriter/constants.py b/novelwriter/constants.py index e15b6733..48d10f51 100644 --- a/novelwriter/constants.py +++ b/novelwriter/constants.py @@ -206,14 +206,14 @@ class nwLabels: nwOutline.SYNOP: QT_TRANSLATE_NOOP("Constant", "Synopsis"), } BUILD_FORMATS = { - "odt": ("odt", QT_TRANSLATE_NOOP("Constant", "Open Document (.odt)")), - "fodt": ("fodt", QT_TRANSLATE_NOOP("Constant", "Flat Open Document (.fodt)")), - "html": ("html", QT_TRANSLATE_NOOP("Constant", "novelWriter HTML (.html)")), - "nwd": ("nwd", QT_TRANSLATE_NOOP("Constant", "novelWriter Markdown (.nwd)")), - "md": ("md", QT_TRANSLATE_NOOP("Constant", "Standard Markdown (.md)")), - "md+": ("md", QT_TRANSLATE_NOOP("Constant", "Extended Markdown (.md)")), - "jhtml": ("json", QT_TRANSLATE_NOOP("Constant", "JSON + novelWriter HTML (.json)")), - "jnwd": ("json", QT_TRANSLATE_NOOP("Constant", "JSON + novelWriter Markdown (.json)")), + "odt": (".odt", QT_TRANSLATE_NOOP("Constant", "Open Document (.odt)")), + "fodt": (".fodt", QT_TRANSLATE_NOOP("Constant", "Flat Open Document (.fodt)")), + "html": (".html", QT_TRANSLATE_NOOP("Constant", "novelWriter HTML (.html)")), + "nwd": (".nwd", QT_TRANSLATE_NOOP("Constant", "novelWriter Markdown (.nwd)")), + "md": (".md", QT_TRANSLATE_NOOP("Constant", "Standard Markdown (.md)")), + "md+": (".md", QT_TRANSLATE_NOOP("Constant", "Extended Markdown (.md)")), + "jhtml": (".json", QT_TRANSLATE_NOOP("Constant", "JSON + novelWriter HTML (.json)")), + "jnwd": (".json", QT_TRANSLATE_NOOP("Constant", "JSON + novelWriter Markdown (.json)")), } # END Class nwLabels diff --git a/novelwriter/core/buildsettings.py b/novelwriter/core/buildsettings.py index bea5f408..1d6606ee 100644 --- a/novelwriter/core/buildsettings.py +++ b/novelwriter/core/buildsettings.py @@ -123,6 +123,7 @@ class FilterMode(Enum): INCLUDED = 2 EXCLUDED = 3 SKIPPED = 4 + ROOT = 5 # END Enum FilterMode @@ -311,7 +312,9 @@ class BuildSettings: """Check if a root handle is allowed in the build.""" return tHandle not in self._skipRoot - def buildItemFilter(self, project: NWProject) -> dict[str, tuple[bool, FilterMode]]: + def buildItemFilter( + self, project: NWProject, withRoots: bool = False + ) -> dict[str, tuple[bool, FilterMode]]: """Return a dictionary of item handles with filter decissions applied. """ @@ -333,6 +336,9 @@ class BuildSettings: if item.isInactiveClass() or (item.itemRoot in self._skipRoot): result[tHandle] = (False, FilterMode.SKIPPED) continue + if withRoots and item.isRootType(): + result[tHandle] = (True, FilterMode.ROOT) + continue if not item.isFileType(): result[tHandle] = (False, FilterMode.SKIPPED) continue diff --git a/novelwriter/core/docbuild.py b/novelwriter/core/docbuild.py index 2270f708..74283540 100644 --- a/novelwriter/core/docbuild.py +++ b/novelwriter/core/docbuild.py @@ -106,25 +106,41 @@ class NWBuildDocument: self._queue.append(item.itemHandle) return + def iterBuild(self, path: Path, bFormat: str) -> Iterable[tuple[int, bool]]: + """Wrapper for builder based on format.""" + if bFormat in ("odt", "fodt"): + yield from self.iterBuildOpenDocument(path, bFormat == "fodt") + elif bFormat in ("html", "jhtml"): + yield from self.iterBuildHTML(path if bFormat == "html" else None) + elif bFormat in ("md", "md+"): + yield from self.iterBuildMarkdown(path, bFormat == "md+") + elif bFormat in ("nwd", "jnwd"): + yield from self.iterBuildNovelWriter(path if bFormat == "nwd" else None) + return + def iterBuildOpenDocument(self, path: Path, isFlat: bool) -> Iterable[tuple[int, bool]]: """Build an Open Document file.""" - makeOdt = ToOdt(self._project, isFlat=isFlat) - self._setupBuild(makeOdt) - makeOdt.initDocument() + makeObj = ToOdt(self._project, isFlat=isFlat) + filtered = self._setupBuild(makeObj) + makeObj.initDocument() for i, tHandle in enumerate(self._queue): - yield i, self._doBuild(makeOdt, tHandle) + self._error = None + if filtered.get(tHandle, (False, 0))[0]: + yield i, self._doBuild(makeObj, tHandle) + else: + yield i, False - makeOdt.closeDocument() + makeObj.closeDocument() self._error = None - self._cache = makeOdt + self._cache = makeObj try: if isFlat: - makeOdt.saveFlatXML(path) + makeObj.saveFlatXML(path) else: - makeOdt.saveOpenDocText(path) + makeObj.saveOpenDocText(path) except Exception as exc: self._error = formatException(exc) @@ -134,21 +150,25 @@ class NWBuildDocument: """Build an HTML file. If path is None, no file is saved. This is used for generating build previews. """ - makeHtml = ToHtml(self._project) - self._setupBuild(makeHtml) + makeObj = ToHtml(self._project) + filtered = self._setupBuild(makeObj) if self._build.getBool("format.replaceTabs"): - makeHtml.replaceTabs() + makeObj.replaceTabs() for i, tHandle in enumerate(self._queue): - yield i, self._doBuild(makeHtml, tHandle) + self._error = None + if filtered.get(tHandle, (False, 0))[0]: + yield i, self._doBuild(makeObj, tHandle) + else: + yield i, False self._error = None - self._cache = makeHtml + self._cache = makeObj if isinstance(path, Path): try: - makeHtml.saveHTML5(path) + makeObj.saveHTML5(path) except Exception as exc: self._error = formatException(exc) @@ -156,35 +176,66 @@ class NWBuildDocument: def iterBuildMarkdown(self, path: Path, extendedMd: bool) -> Iterable[tuple[int, bool]]: """Build a Markdown file.""" - makeMd = ToMarkdown(self._project) - self._setupBuild(makeMd) + makeObj = ToMarkdown(self._project) + filtered = self._setupBuild(makeObj) if extendedMd: - makeMd.setGitHubMarkdown() + makeObj.setGitHubMarkdown() else: - makeMd.setStandardMarkdown() + makeObj.setStandardMarkdown() if self._build.getBool("format.replaceTabs"): - makeMd.replaceTabs(nSpaces=4, spaceChar=" ") + makeObj.replaceTabs(nSpaces=4, spaceChar=" ") for i, tHandle in enumerate(self._queue): - yield i, self._doBuild(makeMd, tHandle) + self._error = None + if filtered.get(tHandle, (False, 0))[0]: + yield i, self._doBuild(makeObj, tHandle) + else: + yield i, False self._error = None - self._cache = makeMd + self._cache = makeObj try: - makeMd.saveMarkdown(path) + makeObj.saveMarkdown(path) except Exception as exc: self._error = formatException(exc) return + def iterBuildNovelWriter(self, path: Path | None) -> Iterable[tuple[int, bool]]: + """Build a novelWriter Markdown file.""" + makeObj = ToMarkdown(self._project) + filtered = self._setupBuild(makeObj) + + makeObj.setKeepMarkdown(True) + if self._build.getBool("format.replaceTabs"): + makeObj.replaceTabs(nSpaces=4, spaceChar=" ") + + for i, tHandle in enumerate(self._queue): + self._error = None + if filtered.get(tHandle, (False, 0))[0]: + yield i, self._doBuild(makeObj, tHandle, convert=False) + else: + yield i, False + + self._error = None + self._cache = makeObj + + if isinstance(path, Path): + try: + makeObj.saveRawMarkdown(path) + except Exception as exc: + self._error = formatException(exc) + + return + ## # Internal Functions ## - def _setupBuild(self, bldObj: Tokenizer): + def _setupBuild(self, bldObj: Tokenizer) -> dict: """Configure the build object.""" # Get Settings fmtTitle = self._build.getStr("headings.fmtTitle") @@ -199,6 +250,7 @@ class NWBuildDocument: incComments = self._build.getBool("text.includeComments") incKeywords = self._build.getBool("text.includeKeywords") incBodyText = self._build.getBool("text.includeBodyText") + noteHeadings = self._build.getBool("text.addNoteHeadings") buildLang = self._build.getStr("format.buildLang") textFont = self._build.getStr("format.textFont") @@ -244,11 +296,12 @@ class NWBuildDocument: bldObj.setColourHeaders(odtAddColours) bldObj.setLanguage(buildLang) - return + filtered = self._build.buildItemFilter(self._project, withRoots=noteHeadings) - def _doBuild(self, bldObj: Tokenizer, tHandle: str) -> bool: + return filtered + + def _doBuild(self, bldObj: Tokenizer, tHandle: str, convert: bool = True) -> bool: """Build a single document and add it to the build object.""" - self._error = None tItem = self._project.tree[tHandle] if tItem is None: self._error = f"Build: Unknown item '{tHandle}'" @@ -258,13 +311,15 @@ class NWBuildDocument: try: if tItem.isRootType() and not tItem.isNovelLike(): bldObj.addRootHeading(tItem.itemHandle) - bldObj.doConvert() + if convert: + bldObj.doConvert() elif tItem.isFileType(): bldObj.setText(tHandle) bldObj.doPreProcessing() bldObj.tokenizeText() bldObj.doHeaders() - bldObj.doConvert() + if convert: + bldObj.doConvert() else: logger.info(f"Build: Skipping '{tHandle}'") diff --git a/novelwriter/core/index.py b/novelwriter/core/index.py index 920eef22..71ff9814 100644 --- a/novelwriter/core/index.py +++ b/novelwriter/core/index.py @@ -786,8 +786,7 @@ class ItemIndex: """Iterate over all item headers of an item. """ if tHandle in self._items: - for sTitle, hItem in self._items[tHandle].items(): - yield sTitle, hItem + yield from self._items[tHandle].items() return def iterAllHeaders(self): diff --git a/novelwriter/tools/manusbuild.py b/novelwriter/tools/manusbuild.py index 875a50b1..7412658a 100644 --- a/novelwriter/tools/manusbuild.py +++ b/novelwriter/tools/manusbuild.py @@ -24,19 +24,21 @@ along with this program. If not, see . from __future__ import annotations import logging +from pathlib import Path from typing import TYPE_CHECKING from PyQt5.QtCore import QSize, Qt, pyqtSlot from PyQt5.QtWidgets import ( - QAbstractButton, QAbstractItemView, QDialog, QDialogButtonBox, QGridLayout, - QHBoxLayout, QLabel, QLineEdit, QListWidget, QListWidgetItem, QProgressBar, - QPushButton, QSplitter, QVBoxLayout, QWidget + QAbstractButton, QAbstractItemView, QDialog, QDialogButtonBox, QFileDialog, + QLabel, QListWidget, QListWidgetItem, QProgressBar, QPushButton, QSplitter, + QVBoxLayout, QWidget ) from novelwriter import CONFIG from novelwriter.common import makeFileNameSafe from novelwriter.constants import nwLabels +from novelwriter.core.docbuild import NWBuildDocument from novelwriter.core.item import NWItem from novelwriter.core.buildsettings import BuildSettings @@ -70,11 +72,11 @@ class GuiManuscriptBuild(QDialog): self.setWindowTitle(self.tr("Build Manuscript")) self.setMinimumWidth(CONFIG.pxInt(500)) - self.setMinimumHeight(CONFIG.pxInt(300)) + self.setMinimumHeight(CONFIG.pxInt(250)) iPx = self.mainTheme.baseIconSize wWin = CONFIG.pxInt(660) - hWin = CONFIG.pxInt(390) + hWin = CONFIG.pxInt(360) pOptions = self.theProject.options self.resize( @@ -138,35 +140,18 @@ class GuiManuscriptBuild(QDialog): self.lblMain.setWordWrap(True) self.lblMain.setFont(font) - self.spaceWidget = QWidget() - self.spaceWidget.setFixedHeight(CONFIG.pxInt(8)) - - # Build Path - self.lblPath = QLabel(self.tr("Build Folder")) - self.buildPath = QLineEdit() - self.buildPath.setText(str(self._build.lastPath)) - self.btnBrowse = QPushButton(self.mainTheme.getIcon("browse"), "") - - self.pathBox = QHBoxLayout() - self.pathBox.addWidget(self.buildPath) - self.pathBox.addWidget(self.btnBrowse) - - # Build Name - self.lblName = QLabel(self.tr("Build Name")) - self.buildName = QLineEdit() - self.btnReset = QPushButton(self.mainTheme.getIcon("revert"), "") - self.btnReset.setToolTip(self.tr("Reset Build Name to default")) - self.btnReset.clicked.connect(self._doResetBuildName) - - self.nameBox = QHBoxLayout() - self.nameBox.addWidget(self.buildName) - self.nameBox.addWidget(self.btnReset) - # Build Progress self.lblProgress = QLabel(self.tr("Build Progress")) + self.buildProgress = QProgressBar() + self.buildProgress.setMinimum(0) self.buildProgress.setValue(0) + self.progressBox = QVBoxLayout() + self.progressBox.addWidget(self.lblProgress) + self.progressBox.addWidget(self.buildProgress) + self.progressBox.setSpacing(CONFIG.pxInt(4)) + # Dialog Buttons self.btnBuild = QPushButton(self.mainTheme.getIcon("export"), self.tr("&Build")) self.dlgButtons = QDialogButtonBox(QDialogButtonBox.Close) @@ -187,26 +172,15 @@ class GuiManuscriptBuild(QDialog): CONFIG.pxInt(pOptions.getInt("GuiManuscriptBuild", "sumWidth", wWin//2)), ]) - self.outerBox = QGridLayout() - self.outerBox.addWidget(self.lblMain, 0, 0, 1, 2, Qt.AlignCenter) - self.outerBox.addWidget(self.spaceWidget, 1, 0, 1, 2) - self.outerBox.addWidget(self.mainSplit, 2, 0, 1, 2) - self.outerBox.addWidget(self.lblPath, 3, 0, 1, 1) - self.outerBox.addLayout(self.pathBox, 3, 1, 1, 1) - self.outerBox.addWidget(self.lblName, 4, 0, 1, 1) - self.outerBox.addLayout(self.nameBox, 4, 1, 1, 1) - self.outerBox.addWidget(self.lblProgress, 5, 0, 1, 1) - self.outerBox.addWidget(self.buildProgress, 5, 1, 1, 1) - self.outerBox.addWidget(self.dlgButtons, 6, 0, 1, 2) - self.outerBox.setRowStretch(2, 1) + self.outerBox = QVBoxLayout() + self.outerBox.addWidget(self.lblMain, 0, Qt.AlignCenter) + self.outerBox.addWidget(self.mainSplit, 1) + self.outerBox.addLayout(self.progressBox, 0) + self.outerBox.addWidget(self.dlgButtons, 0) + self.outerBox.setSpacing(CONFIG.pxInt(12)) self.setLayout(self.outerBox) - if self._build.lastBuildName: - self.buildName.setText(makeFileNameSafe(self._build.lastBuildName)) - else: - self._doResetBuildName() - self.btnBuild.setFocus() self._populateContentList() @@ -252,30 +226,43 @@ class GuiManuscriptBuild(QDialog): self.close() return - @pyqtSlot() - def _doResetBuildName(self): - """Generate a default build name.""" - bName = makeFileNameSafe(f"{self.theProject.data.name} - {self._build.name}") - self.buildName.setText(bName) - self._build.setLastBuildName(bName) - return - ## # Internal Functions ## def _runBuild(self) -> bool: """Run the currently selected build.""" - bFormat = self._getSelectedFormat() - if not bFormat: + selFormat = self._getSelectedFormat() + if not selFormat or selFormat not in nwLabels.BUILD_FORMATS: return False - bPath = self.buildPath.text() - bName = self.buildName.text() + lastName = self._build.lastBuildName + if not lastName: + lastName = f"{self.theProject.data.name} - {self._build.name}.ext" - self._build.setLastFormat(bFormat) - self._build.setLastPath(bPath) - self._build.setLastBuildName(bName) + lastPath = self._build.lastPath + selExt = nwLabels.BUILD_FORMATS[selFormat][0] + selName = Path(makeFileNameSafe(lastName)).with_suffix(selExt) + + self.buildProgress.setValue(0) + savePath, _ = QFileDialog.getSaveFileName( + self, self.tr("Save Manuscript As"), str(lastPath / selName) + ) + if not savePath: + return False + + buildPath = Path(savePath) + + docBuild = NWBuildDocument(self.theProject, self._build) + docBuild.queueAll() + + self.buildProgress.setMaximum(len(docBuild)) + for i, _ in docBuild.iterBuild(buildPath, selFormat): + self.buildProgress.setValue(i+1) + + self._build.setLastFormat(selFormat) + self._build.setLastPath(buildPath.parent) + self._build.setLastBuildName(buildPath.name) return True diff --git a/tests/test_core/test_core_docbuild.py b/tests/test_core/test_core_docbuild.py index 5953a10a..380d1891 100644 --- a/tests/test_core/test_core_docbuild.py +++ b/tests/test_core/test_core_docbuild.py @@ -36,7 +36,7 @@ BUILD_CONF = { "settings": { "filter.includeNovel": True, "filter.includeNotes": True, - "filter.includeInactive": False, + "filter.includeInactive": True, "headings.fmtTitle": "Title: {Title}", "headings.fmtChapter": "Chapter: {Title}", "headings.fmtUnnumbered": "{Title}", @@ -96,7 +96,7 @@ def testCoreDocBuild_OpenDocument(monkeypatch, mockGUI, prjLipsum, fncPath, tstP if docBuild.error: error.append(docBuild.error) - assert count == 21 + assert count == 19 assert error == [] copyfile(docFile, tstFile) @@ -114,7 +114,7 @@ def testCoreDocBuild_OpenDocument(monkeypatch, mockGUI, prjLipsum, fncPath, tstP if docBuild.error: error.append(docBuild.error) - assert count == 21 + assert count == 19 assert error == [] assert docFile.is_file() @@ -137,19 +137,17 @@ def testCoreDocBuild_OpenDocument(monkeypatch, mockGUI, prjLipsum, fncPath, tstP with monkeypatch.context() as mp: mp.setattr("novelwriter.core.toodt.ToOdt.doConvert", causeException) - - docBuild.addDocument("0000000000000") - assert len(docBuild) == 22 + assert len(docBuild) == 21 count = 0 error = [] docFile = fncPath / "Lorem Ipsum Err.fodt" for _, success in docBuild.iterBuildOpenDocument(docFile, True): count += 1 if success else 0 - if docBuild.error: + if not success and docBuild.error: error.append(docBuild.error) - assert count == 3 + assert count == 1 assert error == [ "Build: Failed to build '7a992350f3eb6'", "Build: Failed to build '8c58a65414c23'", @@ -169,7 +167,6 @@ def testCoreDocBuild_OpenDocument(monkeypatch, mockGUI, prjLipsum, fncPath, tstP "Build: Failed to build '2426c6f0ca922'", "Build: Failed to build '60bdf227455cc'", "Build: Failed to build '04468803b92e1'", - "Build: Unknown item '0000000000000'", ] # END Test testCoreDocBuild_OpenDocument @@ -204,7 +201,7 @@ def testCoreDocBuild_HTML(monkeypatch, mockGUI, prjLipsum, fncPath, tstPaths): if docBuild.error: error.append(docBuild.error) - assert count == 21 + assert count == 19 assert error == [] copyfile(docFile, tstFile) @@ -255,7 +252,7 @@ def testCoreDocBuild_Markdown(monkeypatch, mockGUI, prjLipsum, fncPath, tstPaths if docBuild.error: error.append(docBuild.error) - assert count == 21 + assert count == 19 assert error == [] copyfile(docFile, tstFile) @@ -275,7 +272,7 @@ def testCoreDocBuild_Markdown(monkeypatch, mockGUI, prjLipsum, fncPath, tstPaths if docBuild.error: error.append(docBuild.error) - assert count == 21 + assert count == 19 assert error == [] copyfile(docFile, tstFile)