diff --git a/novelwriter/__init__.py b/novelwriter/__init__.py index 85ac0d62..8d1c68f7 100644 --- a/novelwriter/__init__.py +++ b/novelwriter/__init__.py @@ -1,7 +1,6 @@ """ novelWriter – Init File ======================= -Application initialisation File History: Created: 2018-09-22 [0.0.1] diff --git a/novelwriter/assets/icons/typicons_dark/icons.conf b/novelwriter/assets/icons/typicons_dark/icons.conf index e30b48f0..85fa06a7 100644 --- a/novelwriter/assets/icons/typicons_dark/icons.conf +++ b/novelwriter/assets/icons/typicons_dark/icons.conf @@ -19,6 +19,7 @@ licenseurl = https://creativecommons.org/licenses/by-sa/4.0/ add = typ_plus.svg backward = typ_chevron-left.svg bookmark = typ_bookmark.svg +browse = typ_folder-open.svg build_excluded = typ_cancel.svg build_filtered = typ_filter.svg build_included = typ_pin.svg @@ -40,6 +41,7 @@ cls_world = typ_location.svg cross = typ_times.svg down = typ_chevron-down.svg edit = typ_pencil.svg +export = typ_export-button.svg forward = typ_chevron-right.svg maximise = typ_arrow-maximise.svg menu = typ_th-menu.svg @@ -57,6 +59,7 @@ proj_title = mixed_document-title.svg reference = typ_at.svg refresh = typ_refresh.svg remove = typ_minus.svg +revert = typ_refresh-flipped.svg search = typ_search.svg search_cancel = typ_cancel-grey.svg search_case = nw_search-case.svg diff --git a/novelwriter/assets/icons/typicons_dark/typ_export-button.svg b/novelwriter/assets/icons/typicons_dark/typ_export-button.svg new file mode 100644 index 00000000..a82893ff --- /dev/null +++ b/novelwriter/assets/icons/typicons_dark/typ_export-button.svg @@ -0,0 +1,4 @@ + + + + diff --git a/novelwriter/assets/icons/typicons_dark/typ_folder-open.svg b/novelwriter/assets/icons/typicons_dark/typ_folder-open.svg new file mode 100644 index 00000000..072bff87 --- /dev/null +++ b/novelwriter/assets/icons/typicons_dark/typ_folder-open.svg @@ -0,0 +1,4 @@ + + + + diff --git a/novelwriter/assets/icons/typicons_dark/typ_refresh-flipped.svg b/novelwriter/assets/icons/typicons_dark/typ_refresh-flipped.svg new file mode 100644 index 00000000..19f19130 --- /dev/null +++ b/novelwriter/assets/icons/typicons_dark/typ_refresh-flipped.svg @@ -0,0 +1,4 @@ + + + + diff --git a/novelwriter/assets/icons/typicons_light/icons.conf b/novelwriter/assets/icons/typicons_light/icons.conf index fde507ba..b2cacaf1 100644 --- a/novelwriter/assets/icons/typicons_light/icons.conf +++ b/novelwriter/assets/icons/typicons_light/icons.conf @@ -19,6 +19,7 @@ licenseurl = https://creativecommons.org/licenses/by-sa/4.0/ add = typ_plus.svg backward = typ_chevron-left.svg bookmark = typ_bookmark.svg +browse = typ_folder-open.svg build_excluded = typ_cancel.svg build_filtered = typ_filter.svg build_included = typ_pin.svg @@ -40,6 +41,7 @@ cls_world = typ_location.svg cross = typ_times.svg down = typ_chevron-down.svg edit = typ_pencil.svg +export = typ_export-button.svg forward = typ_chevron-right.svg maximise = typ_arrow-maximise.svg menu = typ_th-menu.svg @@ -57,6 +59,7 @@ proj_title = mixed_document-title.svg reference = typ_at.svg refresh = typ_refresh.svg remove = typ_minus.svg +revert = typ_refresh-flipped.svg search = typ_search.svg search_cancel = typ_cancel-grey.svg search_case = nw_search-case.svg diff --git a/novelwriter/assets/icons/typicons_light/typ_export-button.svg b/novelwriter/assets/icons/typicons_light/typ_export-button.svg new file mode 100644 index 00000000..c74e8814 --- /dev/null +++ b/novelwriter/assets/icons/typicons_light/typ_export-button.svg @@ -0,0 +1,4 @@ + + + + diff --git a/novelwriter/assets/icons/typicons_light/typ_folder-open.svg b/novelwriter/assets/icons/typicons_light/typ_folder-open.svg new file mode 100644 index 00000000..69c4ab24 --- /dev/null +++ b/novelwriter/assets/icons/typicons_light/typ_folder-open.svg @@ -0,0 +1,4 @@ + + + + diff --git a/novelwriter/assets/icons/typicons_light/typ_refresh-flipped.svg b/novelwriter/assets/icons/typicons_light/typ_refresh-flipped.svg new file mode 100644 index 00000000..0987b6a5 --- /dev/null +++ b/novelwriter/assets/icons/typicons_light/typ_refresh-flipped.svg @@ -0,0 +1,4 @@ + + + + diff --git a/novelwriter/common.py b/novelwriter/common.py index 76779604..739c9e53 100644 --- a/novelwriter/common.py +++ b/novelwriter/common.py @@ -27,6 +27,7 @@ import json import uuid import hashlib import logging +import unicodedata import xml.etree.ElementTree as ET from typing import Any, Literal @@ -469,8 +470,7 @@ def xmlIndent(tree: ET.Element | ET.ElementTree): # =============================================================================================== # def readTextFile(path: str | Path) -> str: - """Read the content of a text file in a robust manner. - """ + """Read the content of a text file in a robust manner.""" path = Path(path) if not path.is_file(): return "" @@ -482,14 +482,13 @@ def readTextFile(path: str | Path) -> str: return "" -def makeFileNameSafe(value: str) -> str: - """Returns a filename safe string of the value. +def makeFileNameSafe(text: str) -> str: + """Return a filename safe string. + See: https://unicode.org/reports/tr15/#Norm_Forms """ - clean = "" - for c in str(value).strip(): - if c.isalpha() or c.isdigit() or c == " ": - clean += c - return clean + text = unicodedata.normalize("NFKC", text).strip() + allowed = (" ", ".", "-", "_") + return "".join(c for c in text if c.isalnum() or c in allowed) def sha256sum(path: str | Path) -> str | None: diff --git a/novelwriter/constants.py b/novelwriter/constants.py index ddcc1004..ce43284c 100644 --- a/novelwriter/constants.py +++ b/novelwriter/constants.py @@ -199,6 +199,16 @@ class nwLabels: nwOutline.CUSTOM: KEY_NAME[nwKeyWords.CUSTOM_KEY], 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)")), + } # END Class nwLabels diff --git a/novelwriter/core/buildsettings.py b/novelwriter/core/buildsettings.py index 34f1e1a3..cf0701e0 100644 --- a/novelwriter/core/buildsettings.py +++ b/novelwriter/core/buildsettings.py @@ -35,7 +35,7 @@ from pathlib import Path from PyQt5.QtCore import QT_TRANSLATE_NOOP from novelwriter.common import checkUuid, isHandle, jsonEncode -from novelwriter.constants import nwFiles, nwHeadFmt +from novelwriter.constants import nwFiles, nwHeadFmt, nwLabels from novelwriter.core.item import NWItem from novelwriter.core.project import NWProject from novelwriter.error import logException @@ -72,6 +72,9 @@ SETTINGS_TEMPLATE = { "format.replaceTabs": (bool, False), "odt.addColours": (bool, True), "html.addStyles": (bool, False), + "build.splitNovel": (bool, False), + "build.splitNotes": (bool, False), + "build.splitChapters": (bool, False), } SETTINGS_LABELS = { @@ -112,6 +115,11 @@ SETTINGS_LABELS = { "html": QT_TRANSLATE_NOOP("Builds", "HTML"), "html.addStyles": QT_TRANSLATE_NOOP("Builds", "Add CSS Styles"), + + "build": QT_TRANSLATE_NOOP("Builds", "Build Options"), + "build.splitNovel": QT_TRANSLATE_NOOP("Builds", "Split Novel Root Folders"), + "build.splitNotes": QT_TRANSLATE_NOOP("Builds", "Split Note Root Folders"), + "build.splitChapters": QT_TRANSLATE_NOOP("Builds", "Split By Chapter Document"), } @@ -137,6 +145,9 @@ class BuildSettings: def __init__(self): self._name = "" self._uuid = str(uuid.uuid4()) + self._path = Path.home() + self._build = "" + self._format = "odt" self._skipRoot = set() self._excluded = set() self._included = set() @@ -158,6 +169,23 @@ class BuildSettings: """The build ID as an UUID.""" return self._uuid + @property + def lastPath(self) -> Path: + """The last used build path.""" + if self._path.is_dir(): + return self._path + return Path.home() + + @property + def lastBuildName(self) -> str: + """The last used build name.""" + return self._build + + @property + def lastFormat(self) -> str: + """The last used build format.""" + return self._format + @property def changed(self) -> bool: """The changed status of the build.""" @@ -214,6 +242,30 @@ class BuildSettings: self._uuid = value return + def setLastPath(self, path: Path | str | None): + """Set the last used build path.""" + if isinstance(path, str): + path = Path(path) + if isinstance(path, Path) and path.is_dir(): + self._path = path + else: + self._path = Path.home() + self._changed = True + return + + def setLastBuildName(self, name: str): + """Set the last used build name.""" + self._build = str(name).strip() + self._changed = True + return + + def setLastFormat(self, key: str): + """Set the last used build format.""" + if key in nwLabels.BUILD_FORMATS: + self._format = key + self._changed = True + return + def setFiltered(self, tHandle: str): """Set an item as filtered.""" self._excluded.discard(tHandle) @@ -325,6 +377,9 @@ class BuildSettings: return { "name": self._name, "uuid": self._uuid, + "path": str(self._path), + "build": self._build, + "format": self._format, "settings": self._settings.copy(), "content": { "included": list(self._included), @@ -343,6 +398,9 @@ class BuildSettings: self.setName(data.get("name", "")) self.setBuildID(data.get("uuid", "")) + self.setLastPath(data.get("path", None)) + self.setLastBuildName(data.get("build", "")) + self.setLastFormat(data.get("format", "odt")) if isinstance(included, list): self._included = set([h for h in included if isHandle(h)]) if isinstance(excluded, list): diff --git a/novelwriter/core/options.py b/novelwriter/core/options.py index cf52a3f2..89cb6ab9 100644 --- a/novelwriter/core/options.py +++ b/novelwriter/core/options.py @@ -63,6 +63,9 @@ VALID_MAP = { "GuiManuscript": { "winWidth", "winHeight", "optsWidth", "viewWidth" }, + "GuiManuscriptBuild": { + "winWidth", "winHeight", "fmtWidth", "optsWidth" + }, } diff --git a/novelwriter/extensions/switchbox.py b/novelwriter/extensions/switchbox.py index f1895ca9..03d8befc 100644 --- a/novelwriter/extensions/switchbox.py +++ b/novelwriter/extensions/switchbox.py @@ -1,7 +1,6 @@ """ novelWriter – Custom Widget: Switch Box ======================================= -A box of icons, labels and switches File History: Created: 2023-04-16 [2.1b1] @@ -22,32 +21,35 @@ General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see . """ +from __future__ import annotations from PyQt5.QtCore import Qt, pyqtSignal +from PyQt5.QtGui import QIcon from PyQt5.QtWidgets import QGridLayout, QLabel, QScrollArea, QSizePolicy, QWidget from novelwriter.extensions.switch import NSwitch class NSwitchBox(QScrollArea): + """Extension: Switch Box Widget + + A widget that can hold a list of switches with labels and optional + icons. The switch toggles emits a common signal with a switch key. + """ switchToggled = pyqtSignal(str, bool) - def __init__(self, parent, baseSize): + def __init__(self, parent: QWidget, baseSize: int): super().__init__(parent=parent) - self._index = 0 self._hSwitch = baseSize self._wSwitch = 2*self._hSwitch self._sIcon = baseSize - self.clear() - return def clear(self): - """Rebuild the content of the core widget. - """ + """Rebuild the content of the core widget.""" self._content = QGridLayout() self._content.setColumnStretch(1, 1) @@ -60,9 +62,8 @@ class NSwitchBox(QScrollArea): return - def addLabel(self, text): - """Add a header label to the content box. - """ + def addLabel(self, text: str): + """Add a header label to the content box.""" label = QLabel(text) font = label.font() font.setBold(True) @@ -71,9 +72,8 @@ class NSwitchBox(QScrollArea): self._bumpIndex() return - def addItem(self, qIcon, text, identifier, default=False): - """Add an item to the content box. - """ + def addItem(self, qIcon: QIcon, text: str, identifier: str, default: bool = False): + """Add an item to the content box.""" icon = QLabel("") icon.setAlignment(Qt.AlignRight | Qt.AlignVCenter) icon.setPixmap(qIcon.pixmap(self._sIcon, self._sIcon)) @@ -100,13 +100,17 @@ class NSwitchBox(QScrollArea): self._bumpIndex() return + def setInnerContentsMargins(self, left: int, top: int, right: int, bottom: int): + """Set the contents margins of the inner layout.""" + self._content.setContentsMargins(left, top, right, bottom) + return + ## # Internal Functions ## - def _emitSwitchSignal(self, identifier, state): - """Emit a signal for a switch toggle. - """ + def _emitSwitchSignal(self, identifier: str, state: bool): + """Emit a signal for a switch toggle.""" self.switchToggled.emit(identifier, state) return diff --git a/novelwriter/gui/theme.py b/novelwriter/gui/theme.py index f1c3f0a4..52bbfb78 100644 --- a/novelwriter/gui/theme.py +++ b/novelwriter/gui/theme.py @@ -454,9 +454,9 @@ class GuiIcons: "view_novel", "view_outline", # General Button Icons - "add", "backward", "bookmark", "checked", "close", "cross", "down", "edit", "forward", - "maximise", "menu", "minimise", "noncheckable", "reference", "refresh", "remove", - "search_replace", "search", "settings", "unchecked", "up", + "add", "backward", "bookmark", "browse", "checked", "close", "cross", "down", "edit", + "export", "forward", "maximise", "menu", "minimise", "noncheckable", "reference", + "refresh", "remove", "revert", "search_replace", "search", "settings", "unchecked", "up", # Switches "sticky-on", "sticky-off", diff --git a/novelwriter/tools/manusbuild.py b/novelwriter/tools/manusbuild.py index 199dbf7f..439976b9 100644 --- a/novelwriter/tools/manusbuild.py +++ b/novelwriter/tools/manusbuild.py @@ -25,7 +25,23 @@ from __future__ import annotations import logging -from PyQt5.QtWidgets import QDialog, QWidget +from typing import TYPE_CHECKING + +from PyQt5.QtCore import Qt, pyqtSlot +from PyQt5.QtWidgets import ( + QAbstractButton, QDialog, QDialogButtonBox, QFrame, QGridLayout, + QHBoxLayout, QLabel, QLineEdit, QListWidget, QListWidgetItem, QProgressBar, + QPushButton, QSplitter, QVBoxLayout, QWidget +) + +from novelwriter import CONFIG +from novelwriter.common import makeFileNameSafe +from novelwriter.constants import nwLabels +from novelwriter.core.buildsettings import BuildSettings +from novelwriter.extensions.switchbox import NSwitchBox + +if TYPE_CHECKING: + from novelwriter.guimain import GuiMain logger = logging.getLogger(__name__) @@ -37,13 +53,146 @@ class GuiManuscriptBuild(QDialog): independently of the Manuscript Build Tool. """ - def __init__(self, parent: QWidget): + def __init__(self, parent: QWidget, mainGui: GuiMain, build: BuildSettings): super().__init__(parent=parent) logger.debug("Create: GuiManuscriptBuild") self.setObjectName("GuiManuscriptBuild") + self.mainGui = mainGui + self.mainTheme = mainGui.mainTheme + self.theProject = mainGui.theProject + self._parent = parent + self._build = build + + self.setWindowTitle(self.tr("Build Manuscript")) + self.setMinimumWidth(CONFIG.pxInt(500)) + self.setMinimumHeight(CONFIG.pxInt(250)) + + wWin = CONFIG.pxInt(660) + hWin = CONFIG.pxInt(350) + + pOptions = self.theProject.options + self.resize( + CONFIG.pxInt(pOptions.getInt("GuiManuscriptBuild", "winWidth", wWin)), + CONFIG.pxInt(pOptions.getInt("GuiManuscriptBuild", "winHeight", hWin)) + ) + + # Formats + # ======= + + self.lblFormat = QLabel("{0}".format(self.tr("Build Format"))) + self.listFormats = QListWidget() + current = None + for key, (_, label) in nwLabels.BUILD_FORMATS.items(): + item = QListWidgetItem() + item.setText(label) + item.setData(Qt.UserRole, key) + self.listFormats.addItem(item) + if key == self._build.lastFormat: + current = item + if current: + self.listFormats.setCurrentItem(current) + + self.formatBox = QVBoxLayout() + self.formatBox.addWidget(self.lblFormat, 0) + self.formatBox.addWidget(self.listFormats, 1) + self.formatBox.setContentsMargins(0, 0, 0, 0) + + self.formatWidget = QWidget() + self.formatWidget.setLayout(self.formatBox) + self.formatWidget.setContentsMargins(0, 0, 0, 0) + + # Build Controls + # ============== + + # Build Options + self.swtOptions = NSwitchBox(self, self.mainTheme.baseIconSize) + self.swtOptions.switchToggled.connect(self._applyBuildOptions) + self.swtOptions.setFrameStyle(QFrame.NoFrame) + self.swtOptions.setInnerContentsMargins(0, 0, 0, 0) + + self.swtOptions.addLabel(self._build.getLabel("build")) + self.swtOptions.addItem( + self.mainTheme.getIcon("cls_novel"), self._build.getLabel("build.splitNovel"), + "build.splitNovel", default=self._build.getBool("build.splitNovel") + ) + self.swtOptions.addItem( + self.mainTheme.getIcon("cls_custom"), self._build.getLabel("build.splitNotes"), + "build.splitNotes", default=self._build.getBool("build.splitNotes") + ) + self.swtOptions.addItem( + self.mainTheme.getIcon("proj_chapter"), self._build.getLabel("build.splitChapters"), + "build.splitChapters", default=self._build.getBool("build.splitChapters") + ) + + # Dialog Controls + # =============== + + # 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() + + # Dialog Buttons + self.dlgButtons = QDialogButtonBox(QDialogButtonBox.Close) + self.dlgButtons.addButton( + QPushButton(self.mainTheme.getIcon("export"), self.tr("&Build")), + QDialogButtonBox.ActionRole + ) + self.dlgButtons.clicked.connect(self._dialogButtonClicked) + + # Assemble GUI + # ============ + + self.mainSplit = QSplitter() + self.mainSplit.addWidget(self.formatWidget) + self.mainSplit.addWidget(self.swtOptions) + self.mainSplit.setHandleWidth(CONFIG.pxInt(16)) + self.mainSplit.setCollapsible(0, False) + self.mainSplit.setCollapsible(1, False) + self.mainSplit.setSizes([ + CONFIG.pxInt(pOptions.getInt("GuiManuscriptBuild", "fmtWidth", int(0.45*wWin))), + CONFIG.pxInt(pOptions.getInt("GuiManuscriptBuild", "optsWidth", int(0.55*wWin))), + ]) + + self.outerBox = QGridLayout() + self.outerBox.addWidget(self.mainSplit, 0, 0, 1, 2) + self.outerBox.addWidget(self.lblPath, 1, 0, 1, 1) + self.outerBox.addLayout(self.pathBox, 1, 1, 1, 1) + self.outerBox.addWidget(self.lblName, 2, 0, 1, 1) + self.outerBox.addLayout(self.nameBox, 2, 1, 1, 1) + self.outerBox.addWidget(self.lblProgress, 3, 0, 1, 1) + self.outerBox.addWidget(self.buildProgress, 3, 1, 1, 1) + self.outerBox.addWidget(self.dlgButtons, 4, 0, 1, 2) + + self.setLayout(self.outerBox) + + if self._build.lastBuildName: + self.buildName.setText(makeFileNameSafe(self._build.lastBuildName)) + else: + self._doResetBuildName() logger.debug("Ready: GuiManuscriptBuild") @@ -54,4 +203,91 @@ class GuiManuscriptBuild(QDialog): logger.debug("Delete: GuiManuscriptBuild") return + ## + # Events + ## + + def closeEvent(self, event): + """Capture the user closing the window so we can save GUI + settings. + """ + self._saveSettings() + event.accept() + self.deleteLater() + return + + ## + # Private Slots + ## + + @pyqtSlot(str, bool) + def _applyBuildOptions(self, key: str, state: bool): + """Set the build options for the build.""" + self._build.setValue(key, state) + return + + @pyqtSlot("QAbstractButton*") + def _dialogButtonClicked(self, button: QAbstractButton): + """Handle button clicks from the dialog button box.""" + role = self.dlgButtons.buttonRole(button) + if role == QDialogButtonBox.ActionRole: + self._runBuild() + elif role == QDialogButtonBox.RejectRole: + 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: + return False + + bPath = self.buildPath.text() + bName = self.buildName.text() + + self._build.setLastFormat(bFormat) + self._build.setLastPath(bPath) + self._build.setLastBuildName(bName) + + return True + + def _getSelectedFormat(self) -> str | None: + """Get the currently selected format.""" + items = self.listFormats.selectedItems() + if items and isinstance(items[0], QListWidgetItem): + return str(items[0].data(Qt.UserRole)) + return None + + def _saveSettings(self): + """Save the user GUI settings.""" + logger.debug("Saving GuiManuscriptBuild settings") + + winWidth = CONFIG.rpxInt(self.width()) + winHeight = CONFIG.rpxInt(self.height()) + + mainSplit = self.mainSplit.sizes() + fmtWidth = CONFIG.rpxInt(mainSplit[0]) + optsWidth = CONFIG.rpxInt(mainSplit[1]) + + pOptions = self.theProject.options + pOptions.setValue("GuiManuscriptBuild", "winWidth", winWidth) + pOptions.setValue("GuiManuscriptBuild", "winHeight", winHeight) + pOptions.setValue("GuiManuscriptBuild", "fmtWidth", fmtWidth) + pOptions.setValue("GuiManuscriptBuild", "optsWidth", optsWidth) + pOptions.saveSettings() + + return + # END Class GuiManuscriptBuild diff --git a/novelwriter/tools/manuscript.py b/novelwriter/tools/manuscript.py index 3a7796cb..05e705ae 100644 --- a/novelwriter/tools/manuscript.py +++ b/novelwriter/tools/manuscript.py @@ -44,6 +44,7 @@ from novelwriter.common import checkInt, fuzzyTime from novelwriter.core.tohtml import ToHtml from novelwriter.core.docbuild import NWBuildDocument from novelwriter.core.buildsettings import BuildCollection, BuildSettings +from novelwriter.tools.manusbuild import GuiManuscriptBuild from novelwriter.tools.manussettings import GuiBuildSettings if TYPE_CHECKING: # pragma: no cover @@ -107,26 +108,16 @@ class GuiManuscript(QDialog): self.btnPreview.clicked.connect(self._generatePreview) self.manPreview = _PreviewWidget(self.mainGui) + self.btnBuild = QPushButton(self.tr("Build")) + self.btnBuild.clicked.connect(self._buildManuscript) + self.menuPrint = QMenu(self) self.aPrintSend = self.menuPrint.addAction(self.tr("Print Preview")) self.aPrintFile = self.menuPrint.addAction(self.tr("Print to PDF")) - self.menuSave = QMenu(self) - self.aSaveODT = self.menuSave.addAction(self.tr("Open Document (.odt)")) - self.aSaveFODT = self.menuSave.addAction(self.tr("Flat Open Document (.fodt)")) - self.aSaveHTM = self.menuSave.addAction(self.tr("novelWriter HTML (.htm)")) - self.aSaveNWD = self.menuSave.addAction(self.tr("novelWriter Markdown (.nwd)")) - self.aSaveMD = self.menuSave.addAction(self.tr("Standard Markdown (.md)")) - self.aSaveGH = self.menuSave.addAction(self.tr("GitHub Markdown (.md)")) - self.aSaveJsonH = self.menuSave.addAction(self.tr("JSON + novelWriter HTML (.json)")) - self.aSaveJsonM = self.menuSave.addAction(self.tr("JSON + novelWriter Markdown (.json)")) - self.btnPrint = QPushButton(self.tr("Print")) self.btnPrint.setMenu(self.menuPrint) - self.btnSave = QPushButton(self.tr("Save As")) - self.btnSave.setMenu(self.menuSave) - self.btnClose = QPushButton(self.tr("Close")) self.btnClose.clicked.connect(self._doClose) @@ -139,7 +130,7 @@ class GuiManuscript(QDialog): self.buildBox.addWidget(self.btnDelete) self.processBox = QHBoxLayout() - self.processBox.addWidget(self.btnSave) + self.processBox.addWidget(self.btnBuild) self.processBox.addWidget(self.btnPrint) self.processBox.addWidget(self.btnClose) @@ -283,6 +274,23 @@ class GuiManuscript(QDialog): return + @pyqtSlot() + def _buildManuscript(self): + """Open the build dialog and build the manuscript.""" + build = self._getSelectedBuild() + if build is None: + return + + dlgBuild = GuiManuscriptBuild(self, self.mainGui, build) + dlgBuild.exec_() + + # After the build is done, save build settings changes + if build.changed: + self._builds.setBuild(build) + self._builds.saveCollection() + + return + @pyqtSlot() def _doClose(self): """Forward the close button to the default close method.""" @@ -514,7 +522,7 @@ class _PreviewWidget(QTextBrowser): if self._docTime > 0: strBuildTime = "%s (%s)" % ( datetime.fromtimestamp(self._docTime).strftime("%x %X"), - fuzzyTime(time() - self._docTime) + fuzzyTime(int(time()) - self._docTime) ) else: strBuildTime = self.tr("Unknown") diff --git a/tests/test_base/test_base_common.py b/tests/test_base/test_base_common.py index c244cd05..7b0f868c 100644 --- a/tests/test_base/test_base_common.py +++ b/tests/test_base/test_base_common.py @@ -25,24 +25,23 @@ import hashlib from pathlib import Path -from mocked import causeOSError from tools import writeFile +from mocked import causeOSError from novelwriter.guimain import GuiMain from novelwriter.common import ( - checkStringNone, checkString, checkInt, checkFloat, checkBool, checkHandle, - checkUuid, checkPath, isHandle, isTitleTag, isItemClass, isItemType, - isItemLayout, hexToInt, minmax, checkIntTuple, formatInt, formatTimeStamp, - formatTime, simplified, yesNo, transferCase, fuzzyTime, numberToRoman, - jsonEncode, readTextFile, makeFileNameSafe, sha256sum, getGuiItem, - NWConfigParser + checkBool, checkFloat, checkHandle, checkInt, checkIntTuple, checkPath, + checkString, checkStringNone, checkUuid, formatInt, formatTime, + formatTimeStamp, fuzzyTime, getGuiItem, hexToInt, isHandle, isItemClass, + isItemLayout, isItemType, isTitleTag, jsonEncode, makeFileNameSafe, minmax, + numberToRoman, NWConfigParser, readTextFile, sha256sum, simplified, + transferCase, yesNo ) @pytest.mark.base def testBaseCommon_CheckStringNone(): - """Test the checkStringNone function. - """ + """Test the checkStringNone function.""" assert checkStringNone("Stuff", "NotNone") == "Stuff" assert checkStringNone("None", "NotNone") is None assert checkStringNone(None, "NotNone") is None @@ -151,8 +150,7 @@ def testBaseCommon_CheckBool(): @pytest.mark.base def testBaseCommon_CheckHandle(): - """Test the checkHandle function. - """ + """Test the checkHandle function.""" assert checkHandle("None", 1, True) is None assert checkHandle("None", 1, False) == 1 assert checkHandle(None, 1, True) is None @@ -165,8 +163,7 @@ def testBaseCommon_CheckHandle(): @pytest.mark.base def testBaseCommon_CheckUuid(): - """Test the checkUuid function. - """ + """Test the checkUuid function.""" testUuid = "e2be99af-f9bf-4403-857a-c3d1ac25abea" assert checkUuid("", None) is None assert checkUuid("e2be99af-f9bf-4403-857a-c3d1ac25abe", None) is None @@ -179,8 +176,7 @@ def testBaseCommon_CheckUuid(): @pytest.mark.base def testBaseCommon_CheckPath(): - """Test the checkPath function. - """ + """Test the checkPath function.""" assert checkPath(Path("test"), None) == Path("test") assert checkPath("test", None) == Path("test") assert checkPath(None, None) is None @@ -192,8 +188,7 @@ def testBaseCommon_CheckPath(): @pytest.mark.base def testBaseCommon_IsHandle(): - """Test the isHandle function. - """ + """Test the isHandle function.""" assert isHandle("47666c91c7ccf") is True assert isHandle("47666C91C7CCF") is False assert isHandle("h7666c91c7ccf") is False @@ -206,8 +201,7 @@ def testBaseCommon_IsHandle(): @pytest.mark.base def testBaseCommon_IsTitleTag(): - """Test the isItemClass function. - """ + """Test the isItemClass function.""" assert isTitleTag("T1234") is True assert isTitleTag("t1234") is False @@ -224,8 +218,7 @@ def testBaseCommon_IsTitleTag(): @pytest.mark.base def testBaseCommon_IsItemClass(): - """Test the isItemClass function. - """ + """Test the isItemClass function.""" assert isItemClass("NO_CLASS") is True assert isItemClass("NOVEL") is True assert isItemClass("PLOT") is True @@ -248,8 +241,7 @@ def testBaseCommon_IsItemClass(): @pytest.mark.base def testBaseCommon_IsItemType(): - """Test the isItemType function. - """ + """Test the isItemType function.""" assert isItemType("NO_TYPE") is True assert isItemType("ROOT") is True assert isItemType("FOLDER") is True @@ -268,8 +260,7 @@ def testBaseCommon_IsItemType(): @pytest.mark.base def testBaseCommon_IsItemLayout(): - """Test the isItemLayout function. - """ + """Test the isItemLayout function.""" assert isItemLayout("NO_LAYOUT") is True assert isItemLayout("DOCUMENT") is True assert isItemLayout("NOTE") is True @@ -293,8 +284,7 @@ def testBaseCommon_IsItemLayout(): @pytest.mark.base def testBaseCommon_HexToInt(): - """Test the hexToInt function. - """ + """Test the hexToInt function.""" assert hexToInt(1) == 0 assert hexToInt("1") == 1 assert hexToInt("0xff") == 255 @@ -307,8 +297,7 @@ def testBaseCommon_HexToInt(): @pytest.mark.base def testBaseCommon_MinMax(): - """Test the minmax function. - """ + """Test the minmax function.""" for i in range(-5, 15): assert 0 <= minmax(i, 0, 10) <= 10 @@ -317,8 +306,7 @@ def testBaseCommon_MinMax(): @pytest.mark.base def testBaseCommon_CheckIntTuple(): - """Test the checkIntTuple function. - """ + """Test the checkIntTuple function.""" assert checkIntTuple(0, (0, 1, 2), 3) == 0 assert checkIntTuple(5, (0, 1, 2), 3) == 3 @@ -327,8 +315,7 @@ def testBaseCommon_CheckIntTuple(): @pytest.mark.base def testBaseCommon_FormatTimeStamp(): - """Test the formatTimeStamp function. - """ + """Test the formatTimeStamp function.""" tTime = time.mktime(time.gmtime(0)) assert formatTimeStamp(tTime, False) == "1970-01-01 00:00:00" assert formatTimeStamp(tTime, True) == "1970-01-01 00.00.00" @@ -338,8 +325,7 @@ def testBaseCommon_FormatTimeStamp(): @pytest.mark.base def testBaseCommon_FormatTime(): - """Test the formatTime function. - """ + """Test the formatTime function.""" assert formatTime("1") == "ERROR" assert formatTime(1.0) == "ERROR" assert formatTime(1) == "00:00:01" @@ -361,8 +347,7 @@ def testBaseCommon_FormatTime(): @pytest.mark.base def testBaseCommon_Simplified(): - """Test the simplified function. - """ + """Test the simplified function.""" assert simplified("Hello World") == "Hello World" assert simplified(" Hello World ") == "Hello World" assert simplified("\tHello\n\r\tWorld") == "Hello World" @@ -372,8 +357,7 @@ def testBaseCommon_Simplified(): @pytest.mark.base def testBaseCommon_YesNo(): - """Test the yesNo function. - """ + """Test the yesNo function.""" # Bool assert yesNo(True) == "yes" assert yesNo(False) == "no" @@ -400,8 +384,7 @@ def testBaseCommon_YesNo(): @pytest.mark.base def testBaseCommon_FormatInt(): - """Test the formatInt function. - """ + """Test the formatInt function.""" # Normal Cases assert formatInt(1) == "1" assert formatInt(12) == "12" @@ -424,8 +407,7 @@ def testBaseCommon_FormatInt(): @pytest.mark.base def testBaseCommon_TransferCase(): - """Test the transferCase function. - """ + """Test the transferCase function.""" assert transferCase(1, "TaRgEt") == "TaRgEt" assert transferCase("source", 1) == 1 assert transferCase("", "TaRgEt") == "TaRgEt" @@ -439,8 +421,7 @@ def testBaseCommon_TransferCase(): @pytest.mark.base def testBaseCommon_FuzzyTime(): - """Test the fuzzyTime function. - """ + """Test the fuzzyTime function.""" assert fuzzyTime(-1) == "in the future" assert fuzzyTime(0) == "just now" assert fuzzyTime(29) == "just now" @@ -475,8 +456,7 @@ def testBaseCommon_FuzzyTime(): @pytest.mark.core def testBaseCommon_RomanNumbers(): - """Test conversion of integers to Roman numbers. - """ + """Test conversion of integers to Roman numbers.""" assert numberToRoman(None, False) == "NAN" assert numberToRoman(0, False) == "OOR" assert numberToRoman(1, False) == "I" @@ -503,8 +483,7 @@ def testBaseCommon_RomanNumbers(): @pytest.mark.base def testBaseCommon_JsonEncode(): - """Test the jsonEncode function. - """ + """Test the jsonEncode function.""" # Wrong type assert jsonEncode(None) == "[]" @@ -587,8 +566,7 @@ def testBaseCommon_JsonEncode(): @pytest.mark.base def testBaseCommon_ReadTextFile(monkeypatch, fncPath, ipsumText): - """Test the readTextFile function. - """ + """Test the readTextFile function.""" testText = "\n\n".join(ipsumText) + "\n" testFile = fncPath / "ipsum.txt" writeFile(testFile, testText) @@ -605,20 +583,20 @@ def testBaseCommon_ReadTextFile(monkeypatch, fncPath, ipsumText): @pytest.mark.base def testBaseCommon_MakeFileNameSafe(): - """Test the makeFileNameSafe function. - """ + """Test the makeFileNameSafe function.""" assert makeFileNameSafe(" aaaa ") == "aaaa" assert makeFileNameSafe("aaaa,bbbb") == "aaaabbbb" assert makeFileNameSafe("aaaa\tbbbb") == "aaaabbbb" assert makeFileNameSafe("aaaa bbbb") == "aaaa bbbb" + assert makeFileNameSafe("æøå") == "æøå" + assert makeFileNameSafe("Stuff œfi2⁵") == "Stuff œfi25" # END Test testBaseCommon_MakeFileNameSafe @pytest.mark.base def testBaseCommon_Sha256Sum(monkeypatch, fncPath, ipsumText): - """Test the sha256sum function. - """ + """Test the sha256sum function.""" longText = 50*(" ".join(ipsumText) + " ") shortText = "This is a short file" noneText = "" @@ -657,8 +635,7 @@ def testBaseCommon_Sha256Sum(monkeypatch, fncPath, ipsumText): @pytest.mark.base def testBaseCommon_GetGuiItem(nwGUI): - """Check the GUI item function. - """ + """Check the GUI item function.""" assert getGuiItem("gibberish") is None assert isinstance(getGuiItem("GuiMain"), GuiMain) @@ -667,8 +644,7 @@ def testBaseCommon_GetGuiItem(nwGUI): @pytest.mark.base def testBaseCommon_NWConfigParser(fncPath): - """Test the NWConfigParser subclass. - """ + """Test the NWConfigParser subclass.""" tstConf = fncPath / "test.cfg" writeFile(tstConf, ( "[main]\n"