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"