Files
novelWriter/novelwriter/core/buildsettings.py
T
Veronica Berglyd Olsen c174f8f931 Update linting for main code
2025-08-27 21:00:09 +02:00

657 lines
24 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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 <https://www.gnu.org/licenses/>.
""" # 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