Merge 2.6.1 release into main

This commit is contained in:
Veronica Berglyd Olsen
2025-02-02 18:54:08 +01:00
34 changed files with 3462 additions and 3069 deletions
@@ -1,6 +1,7 @@
{
"Synopsis": "Sinopsis",
"Short Description": "Breve Descripción",
"Footnotes": "Notas al pie",
"Comment": "Comentario",
"Notes": "Notas",
"Tag": "Etiqueta",
@@ -13,6 +14,7 @@
"Objects": "Objetos",
"Entities": "Entidades",
"Custom": "Otros",
"New Page": "Nueva Página",
"0": "Cero",
"1": "Uno",
"2": "Dos",
+3 -1
View File
@@ -1,6 +1,7 @@
{
"Synopsis": "Synopsis",
"Short Description": "Description sommaire",
"Footnotes": "Notes de bas de page",
"Comment": "Commentaire",
"Notes": "Notes",
"Tag": "Étiquette",
@@ -13,7 +14,8 @@
"Objects": "Objets",
"Entities": "Entités",
"Custom": "Personnalisé",
"0": "Zero",
"New Page": "Nouvelle page",
"0": "Zéro",
"1": "Un",
"2": "Deux",
"3": "Trois",
Binary file not shown.
+48 -25
View File
@@ -32,6 +32,7 @@ import sys
from datetime import datetime
from pathlib import Path
from time import time
from typing import TYPE_CHECKING
from PyQt6.QtCore import (
PYQT_VERSION, PYQT_VERSION_STR, QT_VERSION, QT_VERSION_STR, QLibraryInfo,
@@ -47,6 +48,9 @@ from novelwriter.common import (
from novelwriter.constants import nwFiles, nwUnicode
from novelwriter.error import formatException, logException
if TYPE_CHECKING: # pragma: no cover
from novelwriter.core.projectdata import NWProjectData
logger = logging.getLogger(__name__)
DEF_GUI = "default"
@@ -60,14 +64,15 @@ class Config:
__slots__ = (
"_confPath", "_dataPath", "_homePath", "_backPath", "_appPath", "_appRoot", "_hasError",
"_errData", "_nwLangPath", "_qtLangPath", "_qLocale", "_dLocale", "_dShortDate",
"_dShortDateTime", "_qtTrans", "_recentProjects", "_recentPaths", "_backupPath",
"_dShortDateTime", "_qtTrans", "_manuals", "_recentProjects", "_recentPaths",
"_backupPath",
"appName", "appHandle", "pdfDocs", "guiLocale", "guiTheme", "guiSyntax", "guiFont",
"hideVScroll", "hideHScroll", "lastNotes", "nativeFont", "iconTheme", "iconColTree",
"iconColDocs", "mainWinSize", "welcomeWinSize", "prefsWinSize", "mainPanePos",
"viewPanePos", "outlinePanePos", "autoSaveProj", "autoSaveDoc", "emphLabels",
"backupOnClose", "askBeforeBackup", "textFont", "textWidth", "textMargin", "tabWidth",
"focusWidth", "hideFocusFooter", "showFullPath", "autoSelect", "doJustify",
"appName", "appHandle", "guiLocale", "guiTheme", "guiSyntax", "guiFont", "hideVScroll",
"hideHScroll", "lastNotes", "nativeFont", "iconTheme", "iconColTree", "iconColDocs",
"mainWinSize", "welcomeWinSize", "prefsWinSize", "mainPanePos", "viewPanePos",
"outlinePanePos", "autoSaveProj", "autoSaveDoc", "emphLabels", "backupOnClose",
"askBeforeBackup", "askBeforeExit", "textFont", "textWidth", "textMargin", "tabWidth",
"focusWidth", "hideFocusFooter", "showFullPath", "autoSelect", "doJustify",
"showTabsNSpaces", "showLineEndings", "showMultiSpaces", "doReplace", "doReplaceSQuote",
"doReplaceDQuote", "doReplaceDash", "doReplaceDots", "autoScroll", "autoScrollPos",
"scrollPastEnd", "dialogStyle", "allowOpenDial", "dialogLine", "narratorBreak",
@@ -131,8 +136,11 @@ class Config:
self._qtTrans = {}
# PDF Manual
pdfDocs = self._appPath / "assets" / "manual.pdf"
self.pdfDocs = pdfDocs if pdfDocs.is_file() else None
self._manuals: dict[str, Path] = {}
if (assets := self._appPath / "assets").is_dir():
for item in assets.iterdir():
if item.is_file() and item.stem.startswith("manual") and item.suffix == ".pdf":
self._manuals[item.stem] = item
# User Settings
# =============
@@ -169,6 +177,7 @@ class Config:
self.emphLabels = True # Add emphasis to H1 and H2 item labels
self.backupOnClose = False # Flag for running automatic backups
self.askBeforeBackup = True # Flag for asking before running automatic backup
self.askBeforeExit = True # Flag for asking before exiting the app
# Text Editor Settings
self.textFont = QFont() # Editor font
@@ -291,6 +300,10 @@ class Config:
def hasError(self) -> bool:
return self._hasError
@property
def pdfDocs(self) -> Path | None:
return self._manuals.get(f"manual_{self.locale.name()}", self._manuals.get("manual"))
@property
def locale(self) -> QLocale:
return self._dLocale
@@ -605,6 +618,7 @@ class Config:
self._backupPath = conf.rdPath(sec, "backuppath", self._backupPath)
self.backupOnClose = conf.rdBool(sec, "backuponclose", self.backupOnClose)
self.askBeforeBackup = conf.rdBool(sec, "askbeforebackup", self.askBeforeBackup)
self.askBeforeExit = conf.rdBool(sec, "askbeforeexit", self.askBeforeExit)
# Editor
sec = "Editor"
@@ -719,6 +733,7 @@ class Config:
"backuppath": str(self._backupPath),
"backuponclose": str(self.backupOnClose),
"askbeforebackup": str(self.askBeforeBackup),
"askbeforeexit": str(self.askBeforeExit),
}
conf["Editor"] = {
@@ -822,29 +837,30 @@ class RecentProjects:
def __init__(self, config: Config) -> None:
self._conf = config
self._data = {}
self._data: dict[str, dict[str, str | int]] = {}
self._map: dict[str, str] = {}
return
def loadCache(self) -> bool:
"""Load the cache file for recent projects."""
self._data = {}
self._map = {}
cacheFile = self._conf.dataPath(nwFiles.RECENT_FILE)
if cacheFile.is_file():
try:
with open(cacheFile, mode="r", encoding="utf-8") as inFile:
data = json.load(inFile)
for path, entry in data.items():
self._data[path] = {
"title": entry.get("title", ""),
"words": entry.get("words", 0),
"time": entry.get("time", 0),
}
puuid = str(entry.get("uuid", ""))
title = str(entry.get("title", ""))
words = checkInt(entry.get("words", 0), 0)
saved = checkInt(entry.get("time", 0), 0)
if path and title:
self._setEntry(puuid, path, title, words, saved)
except Exception:
logger.error("Could not load recent project cache")
logException()
return False
return True
def saveCache(self) -> bool:
@@ -859,7 +875,6 @@ class RecentProjects:
logger.error("Could not save recent project cache")
logException()
return False
return True
def listEntries(self) -> list[tuple[str, str, int, int]]:
@@ -869,14 +884,15 @@ class RecentProjects:
for k, e in self._data.items()
]
def update(self, path: str | Path, title: str, words: int, saved: float | int) -> None:
def update(self, path: str | Path, data: NWProjectData, saved: float | int) -> None:
"""Add or update recent cache information on a given project."""
self._data[str(path)] = {
"title": title,
"words": int(words),
"time": int(saved),
}
self.saveCache()
try:
if (remove := self._map.get(data.uuid)) and (remove != str(path)):
self.remove(remove)
self._setEntry(data.uuid, str(path), data.name, sum(data.currCounts), int(saved))
self.saveCache()
except Exception:
pass
return
def remove(self, path: str | Path) -> None:
@@ -886,6 +902,13 @@ class RecentProjects:
self.saveCache()
return
def _setEntry(self, puuid: str, path: str, title: str, words: int, saved: int) -> None:
"""Set an entry in the recent projects record."""
self._data[path] = {"uuid": puuid, "title": title, "words": words, "time": saved}
if puuid:
self._map[puuid] = path
return
class RecentPaths:
+4
View File
@@ -173,6 +173,10 @@ class nwKeyWords:
TAG_KEY, POV_KEY, FOCUS_KEY, CHAR_KEY, PLOT_KEY, TIME_KEY, WORLD_KEY,
OBJECT_KEY, ENTITY_KEY, CUSTOM_KEY, STORY_KEY, MENTION_KEY,
]
CAN_CREATE = [
POV_KEY, FOCUS_KEY, CHAR_KEY, PLOT_KEY, TIME_KEY, WORLD_KEY,
OBJECT_KEY, ENTITY_KEY, CUSTOM_KEY,
]
# Set of Valid Keys
VALID_KEYS = set(ALL_KEYS)
+2 -6
View File
@@ -349,9 +349,7 @@ class NWProject:
# Update recent projects
if storePath := self._storage.storagePath:
CONFIG.recentProjects.update(
storePath, self._data.name, sum(self._data.initCounts), time()
)
CONFIG.recentProjects.update(storePath, self._data, time())
# Check the project tree consistency
# This also handles any orphaned files found
@@ -417,9 +415,7 @@ class NWProject:
# Update recent projects
if storagePath := self._storage.storagePath:
CONFIG.recentProjects.update(
storagePath, self._data.name, sum(self._data.currCounts), saveTime
)
CONFIG.recentProjects.update(storagePath, self._data, saveTime)
SHARED.newStatusMessage(self.tr("Saved Project: {0}").format(self._data.name))
self.setProjectChanged(False)
+14 -5
View File
@@ -302,10 +302,10 @@ class GuiPreferences(NDialog):
self.tr("Include project notes in status bar word count"), self.incNotesWCount
)
# Auto Save
# Behaviour
# =========
title = self.tr("Auto Save")
title = self.tr("Behaviour")
section += 1
self.sidebar.addButton(title, section)
self.mainForm.addGroupLabel(title, section)
@@ -332,6 +332,14 @@ class GuiPreferences(NDialog):
self.tr("How often the project is automatically saved."), unit=self.tr("seconds")
)
# Ask before exiting novelWriter
self.askBeforeExit = NSwitch(self)
self.askBeforeExit.setChecked(CONFIG.askBeforeExit)
self.mainForm.addRow(
self.tr("Ask before exiting novelWriter"), self.askBeforeExit,
self.tr("Only applies when a project is open.")
)
# Project Backup
# ==============
@@ -965,9 +973,10 @@ class GuiPreferences(NDialog):
CONFIG.incNotesWCount = self.incNotesWCount.isChecked()
CONFIG.setTextFont(self._textFont)
# Auto Save
CONFIG.autoSaveDoc = self.autoSaveDoc.value()
CONFIG.autoSaveProj = self.autoSaveProj.value()
# Behaviour
CONFIG.autoSaveDoc = self.autoSaveDoc.value()
CONFIG.autoSaveProj = self.autoSaveProj.value()
CONFIG.askBeforeExit = self.askBeforeExit.isChecked()
# Project Backup
CONFIG.setBackupPath(self.backupPath)
-7
View File
@@ -68,13 +68,6 @@ class nwComment(Enum):
STORY = 7
class nwTrinary(Enum):
NEGATIVE = -1
NEUTRAL = 0
POSITIVE = 1
class nwChange(Enum):
CREATE = 0
+5 -6
View File
@@ -28,7 +28,6 @@ import logging
from PyQt6.QtGui import QColor, QPainter, QPaintEvent
from PyQt6.QtWidgets import QAbstractButton, QWidget
from novelwriter.enum import nwTrinary
from novelwriter.types import QtBlack, QtPaintAntiAlias
logger = logging.getLogger(__name__)
@@ -44,13 +43,13 @@ class StatusLED(QAbstractButton):
self._postitve = QtBlack
self._negative = QtBlack
self._color = QtBlack
self._state = nwTrinary.NEUTRAL
self._state = None
self.setFixedWidth(sW)
self.setFixedHeight(sH)
return
@property
def state(self) -> nwTrinary:
def state(self) -> bool | None:
"""The current state of the LED."""
return self._state
@@ -62,11 +61,11 @@ class StatusLED(QAbstractButton):
self.setState(self._state)
return
def setState(self, state: nwTrinary) -> None:
def setState(self, state: bool | None) -> None:
"""Set the colour state."""
if state == nwTrinary.POSITIVE:
if state is True:
self._color = self._postitve
elif state == nwTrinary.NEGATIVE:
elif state is False:
self._color = self._negative
else:
self._color = self._neutral
+28 -13
View File
@@ -34,7 +34,7 @@ from __future__ import annotations
import bisect
import logging
from enum import Enum
from enum import Enum, IntFlag
from time import time
from PyQt6.QtCore import (
@@ -59,7 +59,7 @@ from novelwriter.constants import nwConst, nwKeyWords, nwShortcode, nwUnicode
from novelwriter.core.document import NWDocument
from novelwriter.enum import (
nwChange, nwComment, nwDocAction, nwDocInsert, nwDocMode, nwItemClass,
nwItemType, nwTrinary
nwItemType
)
from novelwriter.extensions.configlayout import NColorLabel
from novelwriter.extensions.eventfilters import WheelEventFilter
@@ -86,6 +86,13 @@ class _SelectAction(Enum):
MOVE_AFTER = 3
class _TagAction(IntFlag):
NONE = 0b00
FOLLOW = 0b01
CREATE = 0b10
class GuiDocEditor(QPlainTextEdit):
"""Gui Widget: Main Document Editor"""
@@ -1146,11 +1153,11 @@ class GuiDocEditor(QPlainTextEdit):
# Follow
status = self._processTag(cursor=pCursor, follow=False)
if status == nwTrinary.POSITIVE:
if status & _TagAction.FOLLOW:
action = qtAddAction(ctxMenu, self.tr("Follow Tag"))
action.triggered.connect(qtLambda(self._processTag, cursor=pCursor, follow=True))
ctxMenu.addSeparator()
elif status == nwTrinary.NEGATIVE:
elif status & _TagAction.CREATE:
action = qtAddAction(ctxMenu, self.tr("Create Note for Tag"))
action.triggered.connect(qtLambda(self._processTag, cursor=pCursor, create=True))
ctxMenu.addSeparator()
@@ -1916,7 +1923,7 @@ class GuiDocEditor(QPlainTextEdit):
def _processTag(
self, cursor: QTextCursor | None = None, follow: bool = True, create: bool = False
) -> nwTrinary:
) -> _TagAction:
"""Activated by Ctrl+Enter. Checks that we're in a block
starting with '@'. We then find the tag under the cursor and
check that it is not the tag itself. If all this is fine, we
@@ -1926,19 +1933,22 @@ class GuiDocEditor(QPlainTextEdit):
if cursor is None:
cursor = self.textCursor()
status = _TagAction.NONE
block = cursor.block()
text = block.text()
if len(text) == 0:
return nwTrinary.NEUTRAL
return status
if text.startswith("@") and self._docHandle:
isGood, tBits, tPos = SHARED.project.index.scanThis(text)
if (
not isGood or not tBits or tBits[0] == nwKeyWords.TAG_KEY
or tBits[0] not in nwKeyWords.VALID_KEYS
not isGood
or not tBits
or (key := tBits[0]) == nwKeyWords.TAG_KEY
or key not in nwKeyWords.VALID_KEYS
):
return nwTrinary.NEUTRAL
return status
tag = ""
exist = False
@@ -1955,7 +1965,14 @@ class GuiDocEditor(QPlainTextEdit):
if not tag or tag.startswith("@"):
# The keyword cannot be looked up, so we ignore that
return nwTrinary.NEUTRAL
return status
if not exist and key in nwKeyWords.CAN_CREATE:
# Must only be set if we have a tag selected
status |= _TagAction.CREATE
if exist:
status |= _TagAction.FOLLOW
if follow and exist:
logger.debug("Attempting to follow tag '%s'", tag)
@@ -1967,9 +1984,7 @@ class GuiDocEditor(QPlainTextEdit):
itemClass = nwKeyWords.KEY_CLASS.get(tBits[0], nwItemClass.NO_CLASS)
self.requestNewNoteCreation.emit(tag, itemClass)
return nwTrinary.POSITIVE if exist else nwTrinary.NEGATIVE
return nwTrinary.NEUTRAL
return status
def _emitRenameItem(self, block: QTextBlock) -> None:
"""Emit a signal to request an item be renamed."""
+6 -7
View File
@@ -34,7 +34,6 @@ from PyQt6.QtWidgets import QApplication, QLabel, QStatusBar, QWidget
from novelwriter import CONFIG, SHARED
from novelwriter.common import formatTime
from novelwriter.constants import nwConst
from novelwriter.enum import nwTrinary
from novelwriter.extensions.modified import NClickableLabel
from novelwriter.extensions.statusled import StatusLED
@@ -119,8 +118,8 @@ class GuiMainStatus(QStatusBar):
self.setRefTime(-1.0)
self.setLanguage(*SHARED.spelling.describeDict())
self.setProjectStats(0, 0)
self.setProjectStatus(nwTrinary.NEUTRAL)
self.setDocumentStatus(nwTrinary.NEUTRAL)
self.setProjectStatus(None)
self.setDocumentStatus(None)
self.updateTime()
return
@@ -150,12 +149,12 @@ class GuiMainStatus(QStatusBar):
self._refTime = refTime
return
def setProjectStatus(self, state: nwTrinary) -> None:
def setProjectStatus(self, state: bool | None) -> None:
"""Set the project status colour icon."""
self.projIcon.setState(state)
return
def setDocumentStatus(self, state: nwTrinary) -> None:
def setDocumentStatus(self, state: bool | None) -> None:
"""Set the document status colour icon."""
self.docIcon.setState(state)
return
@@ -218,13 +217,13 @@ class GuiMainStatus(QStatusBar):
@pyqtSlot(bool)
def updateProjectStatus(self, status: bool) -> None:
"""Update the project status."""
self.setProjectStatus(nwTrinary.NEGATIVE if status else nwTrinary.POSITIVE)
self.setProjectStatus(not status)
return
@pyqtSlot(bool)
def updateDocumentStatus(self, status: bool) -> None:
"""Update the document status."""
self.setDocumentStatus(nwTrinary.NEGATIVE if status else nwTrinary.POSITIVE)
self.setDocumentStatus(not status)
return
##
+1 -1
View File
@@ -847,7 +847,7 @@ class GuiMain(QMainWindow):
def closeMain(self) -> bool:
"""Save everything, and close novelWriter."""
if SHARED.hasProject and not SHARED.question("%s<br>%s" % (
if SHARED.hasProject and CONFIG.askBeforeExit and not SHARED.question("%s<br>%s" % (
self.tr("Do you want to exit novelWriter?"),
self.tr("Changes are saved automatically.")
)):