""" novelWriter – Build Settings ============================ File History: Created: 2023-02-14 [2.1b1] BuildSettings Created: 2023-05-22 [2.1b1] BuildCollection This file is a part of novelWriter Copyright (C) 2023 Veronica Berglyd Olsen and novelWriter contributors This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU 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 . """ # noqa from __future__ import annotations import json import logging import uuid from enum import Enum from pathlib import Path from typing import TYPE_CHECKING from PyQt6.QtCore import QT_TRANSLATE_NOOP, QCoreApplication from novelwriter import CONFIG from novelwriter.common import checkUuid, isHandle, jsonEncode from novelwriter.constants import nwFiles, nwHeadFmt, nwStyles from novelwriter.core.project import NWProject from novelwriter.enum import nwBuildFmt from novelwriter.error import logException if TYPE_CHECKING: from collections.abc import Iterable logger = logging.getLogger(__name__) T_BuildValue = str | int | float | bool # The Settings Template # ===================== # Each entry contains a tuple on the form: (type, default) SETTINGS_TEMPLATE: dict[str, tuple[type, T_BuildValue]] = { "filter.includeNovel": (bool, True), "filter.includeNotes": (bool, False), "filter.includeInactive": (bool, False), "headings.fmtPart": (str, nwHeadFmt.TITLE), "headings.fmtChapter": (str, nwHeadFmt.TITLE), "headings.fmtUnnumbered": (str, nwHeadFmt.TITLE), "headings.fmtScene": (str, "* * *"), "headings.fmtAltScene": (str, ""), "headings.fmtSection": (str, ""), "headings.hidePart": (bool, False), "headings.hideChapter": (bool, False), "headings.hideUnnumbered": (bool, False), "headings.hideScene": (bool, False), "headings.hideAltScene": (bool, False), "headings.hideSection": (bool, True), "headings.centerTitle": (bool, True), "headings.centerPart": (bool, True), "headings.centerChapter": (bool, False), "headings.centerScene": (bool, False), "headings.breakTitle": (bool, False), "headings.breakPart": (bool, True), "headings.breakChapter": (bool, True), "headings.breakScene": (bool, False), "text.includeSynopsis": (bool, False), "text.includeComments": (bool, False), "text.includeStory": (bool, False), "text.includeNotes": (bool, False), "text.includeKeywords": (bool, False), "text.includeBodyText": (bool, True), "text.ignoredKeywords": (str, ""), "text.addNoteHeadings": (bool, True), "format.textFont": (str, CONFIG.textFont.toString()), "format.lineHeight": (float, 1.15), "format.justifyText": (bool, False), "format.stripUnicode": (bool, False), "format.replaceTabs": (bool, False), "format.keepBreaks": (bool, True), "format.showDialogue": (bool, False), "format.firstLineIndent": (bool, False), "format.firstIndentWidth": (float, 1.4), "format.indentFirstPar": (bool, False), "format.titleMarginT": (float, nwStyles.T_MARGIN["H0"][0]), "format.titleMarginB": (float, nwStyles.T_MARGIN["H0"][1]), "format.h1MarginT": (float, nwStyles.T_MARGIN["H1"][0]), "format.h1MarginB": (float, nwStyles.T_MARGIN["H1"][1]), "format.h2MarginT": (float, nwStyles.T_MARGIN["H2"][0]), "format.h2MarginB": (float, nwStyles.T_MARGIN["H2"][1]), "format.h3MarginT": (float, nwStyles.T_MARGIN["H3"][0]), "format.h3MarginB": (float, nwStyles.T_MARGIN["H3"][1]), "format.h4MarginT": (float, nwStyles.T_MARGIN["H4"][0]), "format.h4MarginB": (float, nwStyles.T_MARGIN["H4"][1]), "format.textMarginT": (float, nwStyles.T_MARGIN["TT"][0]), "format.textMarginB": (float, nwStyles.T_MARGIN["TT"][1]), "format.sepMarginT": (float, nwStyles.T_MARGIN["SP"][0]), "format.sepMarginB": (float, nwStyles.T_MARGIN["SP"][1]), "format.pageUnit": (str, "cm"), "format.pageSize": (str, "A4"), "format.pageWidth": (float, 21.0), "format.pageHeight": (float, 29.7), "format.topMargin": (float, 2.0), "format.bottomMargin": (float, 2.0), "format.leftMargin": (float, 2.0), "format.rightMargin": (float, 2.0), "doc.pageHeader": (str, nwHeadFmt.DOC_AUTO), "doc.pageCountOffset": (int, 0), "doc.colorHeadings": (bool, True), "doc.scaleHeadings": (bool, True), "doc.boldHeadings": (bool, True), "html.addStyles": (bool, True), "html.preserveTabs": (bool, False), } SETTINGS_LABELS = { "filter": QT_TRANSLATE_NOOP("Builds", "Document Filters"), "filter.includeNovel": QT_TRANSLATE_NOOP("Builds", "Novel Documents"), "filter.includeNotes": QT_TRANSLATE_NOOP("Builds", "Project Notes"), "filter.includeInactive": QT_TRANSLATE_NOOP("Builds", "Inactive Documents"), "headings": QT_TRANSLATE_NOOP("Builds", "Headings"), "headings.fmtPart": QT_TRANSLATE_NOOP("Builds", "Partition Format"), "headings.fmtChapter": QT_TRANSLATE_NOOP("Builds", "Chapter Format"), "headings.fmtUnnumbered": QT_TRANSLATE_NOOP("Builds", "Unnumbered Format"), "headings.fmtScene": QT_TRANSLATE_NOOP("Builds", "Scene Format"), "headings.fmtAltScene": QT_TRANSLATE_NOOP("Builds", "Alt. Scene Format"), "headings.fmtSection": QT_TRANSLATE_NOOP("Builds", "Section Format"), "headings.styleTitle": QT_TRANSLATE_NOOP("Builds", "Title Styling"), "headings.stylePart": QT_TRANSLATE_NOOP("Builds", "Partition Styling"), "headings.styleChapter": QT_TRANSLATE_NOOP("Builds", "Chapter Styling"), "headings.styleScene": QT_TRANSLATE_NOOP("Builds", "Scene Styling"), "text.grpContent": QT_TRANSLATE_NOOP("Builds", "Text Content"), "text.includeSynopsis": QT_TRANSLATE_NOOP("Builds", "Include Synopsis"), "text.includeComments": QT_TRANSLATE_NOOP("Builds", "Include Comments"), "text.includeStory": QT_TRANSLATE_NOOP("Builds", "Include Story Structure"), "text.includeNotes": QT_TRANSLATE_NOOP("Builds", "Include Manuscript Notes"), "text.includeKeywords": QT_TRANSLATE_NOOP("Builds", "Include Keywords"), "text.includeBodyText": QT_TRANSLATE_NOOP("Builds", "Include Body Text"), "text.ignoredKeywords": QT_TRANSLATE_NOOP("Builds", "Ignore These Keywords"), "text.addNoteHeadings": QT_TRANSLATE_NOOP("Builds", "Add Titles for Notes"), "format.grpFormat": QT_TRANSLATE_NOOP("Builds", "Text Format"), "format.textFont": QT_TRANSLATE_NOOP("Builds", "Text Font"), "format.lineHeight": QT_TRANSLATE_NOOP("Builds", "Line Height"), "format.justifyText": QT_TRANSLATE_NOOP("Builds", "Justify Text Margins"), "format.stripUnicode": QT_TRANSLATE_NOOP("Builds", "Replace Unicode Characters"), "format.replaceTabs": QT_TRANSLATE_NOOP("Builds", "Replace Tabs with Spaces"), "format.keepBreaks": QT_TRANSLATE_NOOP("Builds", "Preserve Hard Line Breaks"), "format.showDialogue": QT_TRANSLATE_NOOP("Builds", "Apply Dialogue Highlighting"), "format.grpParIndent": QT_TRANSLATE_NOOP("Builds", "First Line Indent"), "format.firstLineIndent": QT_TRANSLATE_NOOP("Builds", "Enable Indent"), "format.firstIndentWidth": QT_TRANSLATE_NOOP("Builds", "Indent Width"), "format.indentFirstPar": QT_TRANSLATE_NOOP("Builds", "Indent First Paragraph"), "format.grpMargins": QT_TRANSLATE_NOOP("Builds", "Text Margins"), "format.titleMargin": QT_TRANSLATE_NOOP("Builds", "Title and Partition"), "format.h1Margin": QT_TRANSLATE_NOOP("Builds", "Heading 1 and Chapter"), "format.h2Margin": QT_TRANSLATE_NOOP("Builds", "Heading 2 and Scene"), "format.h3Margin": QT_TRANSLATE_NOOP("Builds", "Heading 3 and Section"), "format.h4Margin": QT_TRANSLATE_NOOP("Builds", "Heading 4"), "format.textMargin": QT_TRANSLATE_NOOP("Builds", "Text Paragraph"), "format.sepMargin": QT_TRANSLATE_NOOP("Builds", "Scene Separator"), "format.grpPage": QT_TRANSLATE_NOOP("Builds", "Page Layout"), "format.pageUnit": QT_TRANSLATE_NOOP("Builds", "Unit"), "format.pageSize": QT_TRANSLATE_NOOP("Builds", "Page Size"), "format.pageMargins": QT_TRANSLATE_NOOP("Builds", "Page Margins"), "doc": QT_TRANSLATE_NOOP("Builds", "Document Style"), "doc.pageHeader": QT_TRANSLATE_NOOP("Builds", "Page Header"), "doc.pageCountOffset": QT_TRANSLATE_NOOP("Builds", "Page Counter Offset"), "doc.colorHeadings": QT_TRANSLATE_NOOP("Builds", "Add Colours to Headings"), "doc.scaleHeadings": QT_TRANSLATE_NOOP("Builds", "Increase Size of Headings"), "doc.boldHeadings": QT_TRANSLATE_NOOP("Builds", "Bold Headings"), "html": QT_TRANSLATE_NOOP("Builds", "HTML Options"), "html.addStyles": QT_TRANSLATE_NOOP("Builds", "Add CSS Styles"), "html.preserveTabs": QT_TRANSLATE_NOOP("Builds", "Preserve Tab Characters"), } RENAMED = { "odt.pageHeader": "doc.pageHeader", "odt.pageCountOffset": "doc.pageCountOffset", } class FilterMode(Enum): """The decision reason for an item in a filtered project.""" UNKNOWN = 0 FILTERED = 1 INCLUDED = 2 EXCLUDED = 3 SKIPPED = 4 ROOT = 5 class BuildSettings: """Core: Build Settings Class. This class manages the build settings for a Manuscript build job. The settings can be packed/unpacked to/from a dictionary for JSON. """ def __init__(self) -> None: self._name = "" self._uuid = str(uuid.uuid4()) self._path = CONFIG.homePath() self._build = "" self._order = 0 self._format = nwBuildFmt.ODT self._skipRoot = set() self._excluded = set() self._included = set() self._settings = {k: v[1] for k, v in SETTINGS_TEMPLATE.items()} self._changed = False @classmethod def fromDict(cls, data: dict) -> BuildSettings: """Create a build settings object from a dict.""" new = cls() new.unpack(data) return new ## # Properties ## @property def name(self) -> str: """Return the build name.""" return self._name @property def buildID(self) -> str: """Return the build ID as a UUID.""" return self._uuid @property def order(self) -> int: """Return the build order.""" return self._order @property def lastBuildPath(self) -> Path: """The last used build path.""" if self._path.is_dir(): return self._path return CONFIG.homePath() @property def lastBuildName(self) -> str: """The last used build name.""" return self._build @property def lastFormat(self) -> nwBuildFmt: """The last used build format.""" return self._format @property def changed(self) -> bool: """The changed status of the build.""" return self._changed ## # Getters ## @staticmethod def getLabel(key: str) -> str: """Extract the GUI label for a specific setting.""" return QCoreApplication.translate("Builds", SETTINGS_LABELS.get(key, "ERROR")) def getStr(self, key: str) -> str: """Type safe value access for strings.""" value = self._settings.get(key, SETTINGS_TEMPLATE.get(key, (None, None))[1]) return str(value) def getBool(self, key: str) -> bool: """Type safe value access for bools.""" value = self._settings.get(key, SETTINGS_TEMPLATE.get(key, (None, None))[1]) return bool(value) def getInt(self, key: str) -> int: """Type safe value access for integers.""" value = self._settings.get(key, SETTINGS_TEMPLATE.get(key, (None, None))[1]) return int(value) if isinstance(value, int | float) else 0 def getFloat(self, key: str) -> float: """Type safe value access for floats.""" value = self._settings.get(key, SETTINGS_TEMPLATE.get(key, (None, None))[1]) return float(value) if isinstance(value, int | float) else 0.0 ## # Setters ## def setName(self, name: str) -> None: """Set the build setting display name.""" self._name = str(name) def setBuildID(self, value: str | uuid.UUID) -> None: """Set a UUID build ID.""" value = checkUuid(value, "") if not value: self._uuid = str(uuid.uuid4()) elif value != self._uuid: self._uuid = value def setOrder(self, value: int) -> None: """Set the build order.""" if isinstance(value, int): self._order = value def setLastBuildPath(self, path: Path | str | None) -> 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 = CONFIG.homePath() self._changed = True def setLastBuildName(self, name: str) -> None: """Set the last used build name.""" self._build = str(name).strip() self._changed = True def setLastFormat(self, value: nwBuildFmt) -> None: """Set the last used build format.""" if isinstance(value, nwBuildFmt): self._format = value self._changed = True def setFiltered(self, tHandle: str) -> None: """Set an item as filtered.""" self._excluded.discard(tHandle) self._included.discard(tHandle) self._changed = True def setIncluded(self, tHandle: str) -> None: """Set an item as explicitly included.""" self._excluded.discard(tHandle) self._included.add(tHandle) self._changed = True def setExcluded(self, tHandle: str) -> None: """Set an item as explicitly excluded.""" self._excluded.add(tHandle) self._included.discard(tHandle) self._changed = True def setAllowRoot(self, tHandle: str, state: bool) -> None: """Set a specific root folder as allowed or not.""" if state is True: self._skipRoot.discard(tHandle) self._changed = True elif state is False: self._skipRoot.add(tHandle) self._changed = True def setValue(self, key: str, value: T_BuildValue) -> None: """Set a specific value for a build setting.""" if (d := SETTINGS_TEMPLATE.get(key)) and len(d) == 2 and isinstance(value, d[0]): self._changed |= (value != self._settings[key]) self._settings[key] = value ## # Methods ## def isRootAllowed(self, tHandle: str) -> bool: """Check if a root handle is allowed in the build.""" return tHandle not in self._skipRoot def buildItemFilter( self, project: NWProject, withRoots: bool = False ) -> dict[str, tuple[bool, FilterMode]]: """Return a dictionary of item handles with filter decisions applied. """ result: dict[str, tuple[bool, FilterMode]] = {} if not isinstance(project, NWProject): return result incNovel = bool(self.getBool("filter.includeNovel")) incNotes = bool(self.getBool("filter.includeNotes")) incInactive = bool(self.getBool("filter.includeInactive")) postponed = [] def allowRoot(rHandle: str | None) -> None: if rHandle in postponed and rHandle in result and rHandle is not None: result[rHandle] = (True, FilterMode.ROOT) postponed.remove(rHandle) for item in project.tree: tHandle = item.itemHandle if item.isInactiveClass() or (item.itemRoot in self._skipRoot): result[tHandle] = (False, FilterMode.SKIPPED) continue if withRoots and item.isRootType(): result[tHandle] = (False, FilterMode.SKIPPED) postponed.append(tHandle) continue if not item.isFileType(): result[tHandle] = (False, FilterMode.SKIPPED) continue if tHandle in self._included: result[tHandle] = (True, FilterMode.INCLUDED) allowRoot(item.itemRoot) continue if tHandle in self._excluded: result[tHandle] = (False, FilterMode.EXCLUDED) continue isNote = item.isNoteLayout() isNovel = item.isDocumentLayout() isActive = item.isActive byActive = isActive or (not isActive and incInactive) byLayout = (isNote and incNotes) or (isNovel and incNovel) isAllowed = byActive and byLayout result[tHandle] = (isAllowed, FilterMode.FILTERED) if isAllowed: allowRoot(item.itemRoot) return result def resetChangedState(self) -> None: """Reset the changed status of the settings object. This must be called when the changes have been safely saved or passed on. """ self._changed = False def pack(self) -> dict: """Pack all content into a JSON compatible dictionary.""" logger.debug("Collecting build setting for '%s'", self._name) return { "name": self._name, "uuid": self._uuid, "path": str(self._path), "build": self._build, "order": self._order, "format": self._format.name, "settings": self._settings.copy(), "content": { "included": list(self._included), "excluded": list(self._excluded), "skipRoot": list(self._skipRoot), } } def unpack(self, data: dict) -> None: """Unpack a dictionary and populate the class.""" content = data.get("content", {}) settings = data.get("settings", {}) included = content.get("included", []) excluded = content.get("excluded", []) skipRoot = content.get("skipRoot", []) self.setName(data.get("name", "")) self.setBuildID(data.get("uuid", "")) self.setOrder(data.get("order", 0)) self.setLastBuildPath(data.get("path", None)) self.setLastBuildName(data.get("build", "")) buildFmt = str(data.get("format", "")) if buildFmt in nwBuildFmt.__members__: self.setLastFormat(nwBuildFmt[buildFmt]) if isinstance(included, list): self._included = set([h for h in included if isHandle(h)]) if isinstance(excluded, list): self._excluded = set([h for h in excluded if isHandle(h)]) if isinstance(skipRoot, list): self._skipRoot = set([h for h in skipRoot if isHandle(h)]) self._settings = {k: v[1] for k, v in SETTINGS_TEMPLATE.items()} if isinstance(settings, dict): for key, value in settings.items(): if isinstance(key, str) and isinstance(value, T_BuildValue): self.setValue(RENAMED.get(key, key), value) self._changed = False @classmethod def duplicate(cls, source: BuildSettings) -> BuildSettings: """Make a copy of another build.""" new = cls() new.unpack(source.pack()) new._uuid = str(uuid.uuid4()) new._name = f"{source.name} 2" return new class BuildCollection: """Core: Build Collection Class. This object holds all the build setting objects defined by the given project. The build settings are saved as a single JSON file in the project folder. """ def __init__(self, project: NWProject) -> None: self._project = project self._lastBuild = "" self._defaultBuild = "" self._builds: dict[str, BuildSettings] = {} self._loadCollection() def __len__(self) -> int: """Return the number of builds.""" return len(self._builds) ## # Properties ## @property def lastBuild(self) -> str: """Return the last active build.""" return self._lastBuild @property def defaultBuild(self) -> str: """Return the default build.""" return self._defaultBuild ## # Getters ## def getBuild(self, buildID: str) -> BuildSettings | None: """Get a specific build settings object.""" return self._builds.get(buildID, None) ## # Setters ## def setBuildsState(self, lastBuild: str, order: list[str]) -> None: """Set the last active build id.""" for i, key in enumerate(order): if build := self._builds.get(key): build.setOrder(i) self._lastBuild = lastBuild self._saveCollection() def setDefaultBuild(self, buildID: str) -> None: """Set the default build id.""" if buildID != self._defaultBuild: self._defaultBuild = buildID self._saveCollection() def setBuild(self, build: BuildSettings) -> None: """Set build settings data in the collection.""" if isinstance(build, BuildSettings): self._builds[build.buildID] = build self._saveCollection() ## # Methods ## def removeBuild(self, buildID: str) -> None: """Remove a build from the collection.""" self._builds.pop(buildID, None) self._saveCollection() def builds(self) -> Iterable[tuple[str, str]]: """Iterate over all available builds.""" for buildID, build in sorted(self._builds.items(), key=lambda x: x[1].order): yield buildID, build.name return ## # Internal Functions ## def _loadCollection(self) -> bool: """Load build collections file.""" buildsFile = self._project.storage.getMetaFile(nwFiles.BUILDS_FILE) if not isinstance(buildsFile, Path): return False data = {} if buildsFile.exists(): logger.debug("Loading builds file") try: with open(buildsFile, mode="r", encoding="utf-8") as inFile: data = json.load(inFile) except Exception: logger.error("Failed to load builds file") logException() return False if not isinstance(data, dict): logger.error("Builds file is not a JSON object") return False builds = data.get("novelWriter.builds", None) if not isinstance(builds, dict): logger.error("No novelWriter.builds in the builds file") return False for key, entry in builds.items(): if key == "lastBuild": self._lastBuild = str(entry) elif key == "defaultBuild": self._defaultBuild = str(entry) elif isinstance(entry, dict): self._builds[key] = BuildSettings.fromDict(entry) return True def _saveCollection(self) -> bool: """Save build collections file.""" buildsFile = self._project.storage.getMetaFile(nwFiles.BUILDS_FILE) if not isinstance(buildsFile, Path): return False logger.debug("Saving builds file") try: data: dict[str, str | dict] = { "lastBuild": self._lastBuild, "defaultBuild": self._defaultBuild, } data.update({k: b.pack() for k, b in self._builds.items()}) with open(buildsFile, mode="w+", encoding="utf-8") as outFile: outFile.write(jsonEncode({"novelWriter.builds": data}, nmax=4)) except Exception: logger.error("Failed to save builds file") logException() return False return True