From ba7fdb314bd63320dd47ced85f20a28c0dded985 Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Tue, 29 Apr 2025 21:26:13 +0200 Subject: [PATCH 01/29] Add a main count setting and use it for the project tree --- novelwriter/core/item.py | 4 ++++ novelwriter/core/itemmodel.py | 2 +- tests/test_core/test_core_item.py | 10 ++++++++++ 3 files changed, 15 insertions(+), 1 deletion(-) diff --git a/novelwriter/core/item.py b/novelwriter/core/item.py index ca3ce498..1dd17e47 100644 --- a/novelwriter/core/item.py +++ b/novelwriter/core/item.py @@ -164,6 +164,10 @@ class NWItem: def paraCount(self) -> int: return self._paraCount + @property + def mainCount(self) -> int: + return self._charCount if CONFIG.useCharCount else self._wordCount + @property def initCount(self) -> int: return self._initCount diff --git a/novelwriter/core/itemmodel.py b/novelwriter/core/itemmodel.py index 6fb2580a..8acdd463 100644 --- a/novelwriter/core/itemmodel.py +++ b/novelwriter/core/itemmodel.py @@ -162,7 +162,7 @@ class ProjectNode: def updateCount(self, propagate: bool = True) -> None: """Update counts, and propagate upwards in the tree.""" - self._count = self._item.wordCount + sum(c._count for c in self._children) # noqa: SLF001 + self._count = self._item.mainCount + sum(c._count for c in self._children) # noqa: SLF001 self._cache[C_COUNT_TEXT] = f"{self._count:n}" if propagate and (parent := self._parent): parent.updateCount() diff --git a/tests/test_core/test_core_item.py b/tests/test_core/test_core_item.py index 395db6da..ad93f7db 100644 --- a/tests/test_core/test_core_item.py +++ b/tests/test_core/test_core_item.py @@ -26,6 +26,7 @@ import pytest from PyQt6.QtGui import QIcon +from novelwriter import CONFIG from novelwriter.core.item import NWItem from novelwriter.core.project import NWProject from novelwriter.enum import nwItemClass, nwItemLayout, nwItemType @@ -168,6 +169,15 @@ def testCoreItem_Setters(mockGUI, mockRnd, fncPath): item.setParaCount(1) assert item.paraCount == 1 + # MainCount + item.setWordCount(123) + item.setCharCount(1234) + CONFIG.useCharCount = False + assert item.mainCount == 123 + CONFIG.useCharCount = True + assert item.mainCount == 1234 + CONFIG.useCharCount = False + # CursorPos item.setCursorPos(None) assert item.cursorPos == 0 From 8de6fd25e270ba3bcd469f352a916dec0d4468a0 Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Tue, 29 Apr 2025 21:36:04 +0200 Subject: [PATCH 02/29] Add character count setting to Preferences --- novelwriter/dialogs/preferences.py | 29 +++++++++++++++------- tests/test_dialogs/test_dlg_preferences.py | 5 ++++ 2 files changed, 25 insertions(+), 9 deletions(-) diff --git a/novelwriter/dialogs/preferences.py b/novelwriter/dialogs/preferences.py index aa4f9cd1..e4bb4bbd 100644 --- a/novelwriter/dialogs/preferences.py +++ b/novelwriter/dialogs/preferences.py @@ -225,6 +225,14 @@ class GuiPreferences(NDialog): self.tr("Turn off to use the Qt font dialog, which may have more options.") ) + # Use Character Count + self.useCharCount = NSwitch(self) + self.useCharCount.setChecked(CONFIG.useCharCount) + self.mainForm.addRow( + self.tr("Prefer character count over word count"), self.useCharCount, + self.tr("Display character count instead where available.") + ) + # Document Style # ============== @@ -957,21 +965,24 @@ class GuiPreferences(NDialog): refreshTree = False # Appearance - guiLocale = self.guiLocale.currentData() - guiTheme = self.guiTheme.currentData() - iconTheme = self.iconTheme.currentData() + guiLocale = self.guiLocale.currentData() + guiTheme = self.guiTheme.currentData() + iconTheme = self.iconTheme.currentData() + useCharCount = self.useCharCount.isChecked() updateTheme |= CONFIG.guiTheme != guiTheme updateTheme |= CONFIG.iconTheme != iconTheme needsRestart |= CONFIG.guiLocale != guiLocale needsRestart |= CONFIG.guiFont != self._guiFont + refreshTree |= CONFIG.useCharCount != useCharCount - CONFIG.guiLocale = guiLocale - CONFIG.guiTheme = guiTheme - CONFIG.iconTheme = iconTheme - CONFIG.hideVScroll = self.hideVScroll.isChecked() - CONFIG.hideHScroll = self.hideHScroll.isChecked() - CONFIG.nativeFont = self.nativeFont.isChecked() + CONFIG.guiLocale = guiLocale + CONFIG.guiTheme = guiTheme + CONFIG.iconTheme = iconTheme + CONFIG.hideVScroll = self.hideVScroll.isChecked() + CONFIG.hideHScroll = self.hideHScroll.isChecked() + CONFIG.nativeFont = self.nativeFont.isChecked() + CONFIG.useCharCount = useCharCount CONFIG.setGuiFont(self._guiFont) # Document Style diff --git a/tests/test_dialogs/test_dlg_preferences.py b/tests/test_dialogs/test_dlg_preferences.py index c7a1b7e4..8e0edfa9 100644 --- a/tests/test_dialogs/test_dlg_preferences.py +++ b/tests/test_dialogs/test_dlg_preferences.py @@ -163,14 +163,17 @@ def testDlgPreferences_Settings(qtbot, monkeypatch, nwGUI, fncPath, tstPaths): mp.setattr(QFontDialog, "getFont", lambda *a, **k: (QFont(), True)) prefs.nativeFont.setChecked(True) # Use OS font dialog prefs.guiFontButton.click() + prefs.hideVScroll.setChecked(True) prefs.hideHScroll.setChecked(True) + prefs.useCharCount.setChecked(True) assert CONFIG.guiLocale != "en_US" assert CONFIG.guiTheme != "default_dark" assert CONFIG.guiFont.family() != "" assert CONFIG.hideVScroll is False assert CONFIG.hideHScroll is False + assert CONFIG.useCharCount is False # Document Style prefs.guiSyntax.setCurrentIndex(prefs.guiSyntax.findData("default_dark")) @@ -178,6 +181,7 @@ def testDlgPreferences_Settings(qtbot, monkeypatch, nwGUI, fncPath, tstPaths): mp.setattr(QFontDialog, "getFont", lambda *a, **k: (QFont(), True)) prefs.nativeFont.setChecked(False) # Use Qt font dialog prefs.textFontButton.click() + prefs.showFullPath.setChecked(False) prefs.incNotesWCount.setChecked(False) @@ -344,6 +348,7 @@ def testDlgPreferences_Settings(qtbot, monkeypatch, nwGUI, fncPath, tstPaths): assert CONFIG.guiFont == QFont() assert CONFIG.hideVScroll is True assert CONFIG.hideHScroll is True + assert CONFIG.useCharCount is True # Document Style assert CONFIG.guiSyntax == "default_dark" From bd4a658bd90fb4c4edac451d2c3c55327c85bc4b Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Tue, 29 Apr 2025 22:00:53 +0200 Subject: [PATCH 03/29] Add project variables for character counts --- novelwriter/config.py | 24 +++++++++-- novelwriter/core/project.py | 12 +++--- novelwriter/core/projectdata.py | 66 ++++++++++++++++++++----------- novelwriter/core/projectxml.py | 24 +++++++---- novelwriter/core/sessions.py | 4 +- novelwriter/core/tree.py | 14 ++++--- novelwriter/guimain.py | 13 +++--- novelwriter/tools/noveldetails.py | 4 +- 8 files changed, 104 insertions(+), 57 deletions(-) diff --git a/novelwriter/config.py b/novelwriter/config.py index 78a4b843..4d90478b 100644 --- a/novelwriter/config.py +++ b/novelwriter/config.py @@ -897,9 +897,10 @@ class RecentProjects: puuid = str(entry.get("uuid", "")) title = str(entry.get("title", "")) words = checkInt(entry.get("words", 0), 0) + chars = checkInt(entry.get("chars", 0), 0) saved = checkInt(entry.get("time", 0), 0) if path and title: - self._setEntry(puuid, path, title, words, saved) + self._setEntry(puuid, path, title, words, chars, saved) except Exception: logger.error("Could not load recent project cache") logException() @@ -932,7 +933,14 @@ class RecentProjects: 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._setEntry( + data.uuid, + str(path), + data.name, + sum(data.currCounts[:2]), + sum(data.currCounts[2:]), + int(saved), + ) self.saveCache() except Exception: pass @@ -945,9 +953,17 @@ class RecentProjects: self.saveCache() return - def _setEntry(self, puuid: str, path: str, title: str, words: int, saved: int) -> None: + def _setEntry( + self, puuid: str, path: str, title: str, words: int, chars: int, saved: int + ) -> None: """Set an entry in the recent projects record.""" - self._data[path] = {"uuid": puuid, "title": title, "words": words, "time": saved} + self._data[path] = { + "uuid": puuid, + "title": title, + "words": words, + "chars": chars, + "time": saved, + } if puuid: self._map[puuid] = path return diff --git a/novelwriter/core/project.py b/novelwriter/core/project.py index 9ad46682..1466f984 100644 --- a/novelwriter/core/project.py +++ b/novelwriter/core/project.py @@ -367,7 +367,7 @@ class NWProject: # Often, the index needs to be rebuilt when updating format self._index.rebuild() - self.updateWordCounts() + self.updateCounts() self._session.startSession() self.setProjectChanged(False) self._valid = True @@ -397,7 +397,7 @@ class NWProject: else: self._data.incSaveCount() - self.updateWordCounts() + self.updateCounts() self.countStatus() xmlWriter = self._storage.getXmlWriter() @@ -515,10 +515,10 @@ class NWProject: # Class Methods ## - def updateWordCounts(self) -> None: - """Update the total word count values.""" - novel, notes = self._tree.sumWords() - self._data.setCurrCounts(novel=novel, notes=notes) + def updateCounts(self) -> None: + """Update the total word and character count values.""" + wNovel, wNotes, cNovel, cNotes = self._tree.sumWords() + self._data.setCurrCounts(wNovel=wNovel, wNotes=wNotes, cNovel=cNovel, cNotes=cNotes) return def countStatus(self) -> None: diff --git a/novelwriter/core/projectdata.py b/novelwriter/core/projectdata.py index 1c2c1f42..15201970 100644 --- a/novelwriter/core/projectdata.py +++ b/novelwriter/core/projectdata.py @@ -66,8 +66,8 @@ class NWProjectData: self._spellLang = None # Project Dictionaries - self._initCounts = [0, 0] - self._currCounts = [0, 0] + self._initCounts = [0, 0, 0, 0] + self._currCounts = [0, 0, 0, 0] self._lastHandle: dict[str, str | None] = { "editor": None, "viewer": None, @@ -148,18 +148,18 @@ class NWProjectData: return self._spellLang @property - def initCounts(self) -> tuple[int, int]: - """Return the initial count of words for novel and note - documents. + def initCounts(self) -> tuple[int, int, int, int]: + """Return the initial count of words and characters for novel + and note documents. """ - return self._initCounts[0], self._initCounts[1] + return self._initCounts[0], self._initCounts[1], self._initCounts[2], self._initCounts[3] @property - def currCounts(self) -> tuple[int, int]: - """Return the current count of words for novel and note - documents. + def currCounts(self) -> tuple[int, int, int, int]: + """Return the current count of words and characters for novel + and note documents. """ - return self._currCounts[0], self._currCounts[1] + return self._currCounts[0], self._currCounts[1], self._currCounts[2], self._currCounts[3] @property def lastHandle(self) -> dict[str, str | None]: @@ -301,22 +301,40 @@ class NWProjectData: self._project.setProjectChanged(True) return - def setInitCounts(self, novel: Any = None, notes: Any = None) -> None: - """Set the word count totals for novel and note files.""" - if novel is not None: - self._initCounts[0] = checkInt(novel, 0) - self._currCounts[0] = checkInt(novel, 0) - if notes is not None: - self._initCounts[1] = checkInt(notes, 0) - self._currCounts[1] = checkInt(notes, 0) + def setInitCounts( + self, wNovel: Any = None, wNotes: Any = None, cNovel: Any = None, cNotes: Any = None + ) -> None: + """Set the count totals for novel and note files.""" + if wNovel is not None: + count = checkInt(wNovel, 0) + self._initCounts[0] = count + self._currCounts[0] = count + if wNotes is not None: + count = checkInt(wNotes, 0) + self._initCounts[1] = count + self._currCounts[1] = count + if cNovel is not None: + count = checkInt(cNovel, 0) + self._initCounts[2] = count + self._currCounts[2] = count + if cNotes is not None: + count = checkInt(cNotes, 0) + self._initCounts[3] = count + self._currCounts[3] = count return - def setCurrCounts(self, novel: Any = None, notes: Any = None) -> None: - """Set the word count totals for novel and note files.""" - if novel is not None: - self._currCounts[0] = checkInt(novel, 0) - if notes is not None: - self._currCounts[1] = checkInt(notes, 0) + def setCurrCounts( + self, wNovel: Any = None, wNotes: Any = None, cNovel: Any = None, cNotes: Any = None + ) -> None: + """Set the count totals for novel and note files.""" + if wNovel is not None: + self._currCounts[0] = checkInt(wNovel, 0) + if wNotes is not None: + self._currCounts[1] = checkInt(wNotes, 0) + if cNovel is not None: + self._currCounts[2] = checkInt(cNovel, 0) + if cNotes is not None: + self._currCounts[3] = checkInt(cNotes, 0) return def setAutoReplace(self, value: dict) -> None: diff --git a/novelwriter/core/projectxml.py b/novelwriter/core/projectxml.py index 461d9a40..1e0e146b 100644 --- a/novelwriter/core/projectxml.py +++ b/novelwriter/core/projectxml.py @@ -46,7 +46,7 @@ if TYPE_CHECKING: logger = logging.getLogger(__name__) FILE_VERSION = "1.5" # The current project file format version -FILE_REVISION = "4" # The current project file format revision +FILE_REVISION = "5" # The current project file format revision HEX_VERSION = 0x0105 NUM_VERSION = { @@ -109,6 +109,8 @@ class ProjectXMLReader: Rev 3: Added TEMPLATE class. 2.3. Rev 4: Added shape attribute to status and importance entry nodes. 2.5. + Rev 5: Added novelChars and notesChars attributes to content + node. 2.7 RC 1. """ def __init__(self, path: str | Path) -> None: @@ -286,9 +288,9 @@ class ProjectXMLReader: elif xItem.tag == "spellLang": # Changed to spellChecking in 1.5 data.setSpellLang(xItem.text) elif xItem.tag == "novelWordCount": # Moved to content attribute in 1.5 - data.setInitCounts(novel=xItem.text) + data.setInitCounts(wNovel=xItem.text) elif xItem.tag == "notesWordCount": # Moved to content attribute in 1.5 - data.setInitCounts(notes=xItem.text) + data.setInitCounts(wNotes=xItem.text) return @@ -298,8 +300,13 @@ class ProjectXMLReader: """Parse the content section of the XML file.""" logger.debug("Parsing section") - data.setInitCounts(novel=xSection.attrib.get("novelWords", None)) # Moved in 1.5 - data.setInitCounts(notes=xSection.attrib.get("notesWords", None)) # Moved in 1.5 + # Moved in 1.5 + data.setInitCounts( + wNovel=xSection.attrib.get("novelWords", None), + wNotes=xSection.attrib.get("notesWords", None), + cNovel=xSection.attrib.get("novelChars", None), + cNotes=xSection.attrib.get("notesChars", None), + ) for xItem in xSection: if xItem.tag != "item": @@ -527,10 +534,13 @@ class ProjectXMLWriter: self._packSingleValue(xImport, "entry", label, attrib=attrib) # Save Tree Content + counts = data.currCounts contAttr = { "items": str(len(content)), - "novelWords": str(data.currCounts[0]), - "notesWords": str(data.currCounts[1]), + "novelWords": str(counts[0]), + "notesWords": str(counts[1]), + "novelChars": str(counts[2]), + "notesChars": str(counts[3]), } xContent = ET.SubElement(xRoot, "content", attrib=contAttr) diff --git a/novelwriter/core/sessions.py b/novelwriter/core/sessions.py index 6f7fcef4..659b6f9e 100644 --- a/novelwriter/core/sessions.py +++ b/novelwriter/core/sessions.py @@ -79,8 +79,8 @@ class NWSessionLog: return False now = time() - iNovel, iNotes = self._project.data.initCounts - cNovel, cNotes = self._project.data.currCounts + iNovel, iNotes, _, _ = self._project.data.initCounts + cNovel, cNotes, _, _ = self._project.data.currCounts iTotal = iNovel + iNotes wDiff = cNovel + cNotes - iTotal sTime = now - self._start diff --git a/novelwriter/core/tree.py b/novelwriter/core/tree.py index a3c93745..b9d569c9 100644 --- a/novelwriter/core/tree.py +++ b/novelwriter/core/tree.py @@ -410,16 +410,20 @@ class NWTree: return True - def sumWords(self) -> tuple[int, int]: - """Loop over all entries and add up the word counts.""" - noteWords = 0 + def sumWords(self) -> tuple[int, int, int, int]: + """Loop over all entries and add up the word and char counts.""" novelWords = 0 + notesWords = 0 + novelChars = 0 + notesChars = 0 for item in self._items.values(): if item.itemLayout == nwItemLayout.NOTE: - noteWords += item.wordCount + notesWords += item.wordCount + notesChars += item.charCount elif item.itemLayout == nwItemLayout.DOCUMENT: novelWords += item.wordCount - return novelWords, noteWords + novelChars += item.charCount + return novelWords, notesWords, novelChars, notesChars ## # Tree Item Methods diff --git a/novelwriter/guimain.py b/novelwriter/guimain.py index 7a03076f..ee32c547 100644 --- a/novelwriter/guimain.py +++ b/novelwriter/guimain.py @@ -1261,15 +1261,14 @@ class GuiMain(QMainWindow): if self._lastTotalCount != currentTotalCount: self._lastTotalCount = currentTotalCount - SHARED.project.updateWordCounts() + SHARED.project.updateCounts() if CONFIG.incNotesWCount: - iTotal = sum(SHARED.project.data.initCounts) - cTotal = sum(SHARED.project.data.currCounts) - self.mainStatus.setProjectStats(cTotal, cTotal - iTotal) + iTotal = sum(SHARED.project.data.initCounts[:2]) + cTotal = sum(SHARED.project.data.currCounts[:2]) else: - iNovel, _ = SHARED.project.data.initCounts - cNovel, _ = SHARED.project.data.currCounts - self.mainStatus.setProjectStats(cNovel, cNovel - iNovel) + iTotal = SHARED.project.data.initCounts[0] + cTotal = SHARED.project.data.currCounts[0] + self.mainStatus.setProjectStats(cTotal, cTotal - iTotal) return diff --git a/novelwriter/tools/noveldetails.py b/novelwriter/tools/noveldetails.py index ec42ccf1..d0628339 100644 --- a/novelwriter/tools/noveldetails.py +++ b/novelwriter/tools/noveldetails.py @@ -256,8 +256,8 @@ class _OverviewPage(NScrollablePage): def updateProjectData(self) -> None: """Load information about the project.""" project = SHARED.project - project.updateWordCounts() - wcNovel, wcNotes = project.data.currCounts + project.updateCounts() + wcNovel, wcNotes, _, _ = project.data.currCounts self.projName.setText(project.data.name) self.projRevisions.setText(f"{project.data.saveCount:n}") From 67d0b486e91af31fc722647b5c93a3067bb259af Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Tue, 29 Apr 2025 22:49:22 +0200 Subject: [PATCH 04/29] Update tests --- novelwriter/core/project.py | 2 +- novelwriter/core/tree.py | 2 +- sample/nwProject.nwx | 6 +- tests/files/nwProject-1.5.nwx | 89 ++++++---- tests/lipsum/nwProject.nwx | 8 +- .../coreProject_NewFileFolder_nwProject.nwx | 4 +- .../coreProject_NewRoot_nwProject.nwx | 4 +- .../coreTools_DocDuplicator_nwProject.nwx | 4 +- .../coreTools_ProjectBuilderA_nwProject.nwx | 4 +- .../coreTools_ProjectBuilderB_nwProject.nwx | 4 +- .../reference/fmtToDocX_SaveDocument_app.xml | 4 +- .../reference/fmtToDocX_SaveDocument_core.xml | 6 +- .../guiEditor_Main_Final_nwProject.nwx | 6 +- .../guiEditor_Main_Initial_nwProject.nwx | 4 +- tests/reference/projectXML_ReadCurrent.json | 160 ++++++++++++++---- tests/reference/projectXML_ReadLegacy10.nwx | 4 +- tests/reference/projectXML_ReadLegacy11.nwx | 4 +- tests/reference/projectXML_ReadLegacy12.nwx | 4 +- tests/reference/projectXML_ReadLegacy13.nwx | 4 +- tests/reference/projectXML_ReadLegacy14.nwx | 4 +- tests/test_core/test_core_projectxml.py | 77 +++++---- tests/test_core/test_core_tree.py | 2 +- 22 files changed, 263 insertions(+), 143 deletions(-) diff --git a/novelwriter/core/project.py b/novelwriter/core/project.py index 1466f984..e16ad263 100644 --- a/novelwriter/core/project.py +++ b/novelwriter/core/project.py @@ -517,7 +517,7 @@ class NWProject: def updateCounts(self) -> None: """Update the total word and character count values.""" - wNovel, wNotes, cNovel, cNotes = self._tree.sumWords() + wNovel, wNotes, cNovel, cNotes = self._tree.sumCounts() self._data.setCurrCounts(wNovel=wNovel, wNotes=wNotes, cNovel=cNovel, cNotes=cNotes) return diff --git a/novelwriter/core/tree.py b/novelwriter/core/tree.py index b9d569c9..14158041 100644 --- a/novelwriter/core/tree.py +++ b/novelwriter/core/tree.py @@ -410,7 +410,7 @@ class NWTree: return True - def sumWords(self) -> tuple[int, int, int, int]: + def sumCounts(self) -> tuple[int, int, int, int]: """Loop over all entries and add up the word and char counts.""" novelWords = 0 notesWords = 0 diff --git a/sample/nwProject.nwx b/sample/nwProject.nwx index f153e61d..d20ba88e 100644 --- a/sample/nwProject.nwx +++ b/sample/nwProject.nwx @@ -1,6 +1,6 @@ - - + + Sample Project Jane Smith @@ -36,7 +36,7 @@ Main - + Novel diff --git a/tests/files/nwProject-1.5.nwx b/tests/files/nwProject-1.5.nwx index 41f19af7..cd103d11 100644 --- a/tests/files/nwProject-1.5.nwx +++ b/tests/files/nwProject-1.5.nwx @@ -1,13 +1,13 @@ - - + + Sample Project Jane Smith - yes + no en_GB - en_GB + None 636b6aa9b697b 636b6aa9b697b @@ -20,32 +20,33 @@ D - New + New Notes - Started - 1st Draft - 2nd Draft - 3rd Draft - Finished + Started + 1st Draft + 2nd Draft + 3rd Draft + Finished None - Minor - Major - Main + Background + Minor + Major + Main - + Novel - + Title Page - + Page @@ -57,28 +58,28 @@ Chapter One - + Making a Scene - + Another Scene - + Interlude - + A Note on Structure - + Chapter Two - - We Found John! + + We Found John! @@ -89,8 +90,8 @@ Title Page - - Chapter One + + Chapter One @@ -101,28 +102,28 @@ Main Characters - - John Smith + + John Smith - - Jane Smith + + Jane Smith Locations - - Earth + + Earth - - Space + + Space - - Mars + + Mars @@ -136,7 +137,23 @@ Old File - + + + Templates + + + + Scene + + + + Chapter + + + + Character Note + + Trash diff --git a/tests/lipsum/nwProject.nwx b/tests/lipsum/nwProject.nwx index 1a3731af..de32f1f6 100644 --- a/tests/lipsum/nwProject.nwx +++ b/tests/lipsum/nwProject.nwx @@ -1,6 +1,6 @@ - - + + Lorem Ipsum lipsum.com @@ -31,7 +31,7 @@ Main - + Novel @@ -116,7 +116,7 @@ Ancient Europe - + Trash diff --git a/tests/reference/coreProject_NewFileFolder_nwProject.nwx b/tests/reference/coreProject_NewFileFolder_nwProject.nwx index 80142d13..8fefad66 100644 --- a/tests/reference/coreProject_NewFileFolder_nwProject.nwx +++ b/tests/reference/coreProject_NewFileFolder_nwProject.nwx @@ -1,5 +1,5 @@ - + New Project Jane Doe @@ -28,7 +28,7 @@ Main - + Novel diff --git a/tests/reference/coreProject_NewRoot_nwProject.nwx b/tests/reference/coreProject_NewRoot_nwProject.nwx index 5d583362..e188464c 100644 --- a/tests/reference/coreProject_NewRoot_nwProject.nwx +++ b/tests/reference/coreProject_NewRoot_nwProject.nwx @@ -1,5 +1,5 @@ - + New Project Jane Doe @@ -28,7 +28,7 @@ Main - + Novel diff --git a/tests/reference/coreTools_DocDuplicator_nwProject.nwx b/tests/reference/coreTools_DocDuplicator_nwProject.nwx index 2f571386..26b0ced7 100644 --- a/tests/reference/coreTools_DocDuplicator_nwProject.nwx +++ b/tests/reference/coreTools_DocDuplicator_nwProject.nwx @@ -1,5 +1,5 @@ - + New Project Jane Doe @@ -28,7 +28,7 @@ Main - + Novel diff --git a/tests/reference/coreTools_ProjectBuilderA_nwProject.nwx b/tests/reference/coreTools_ProjectBuilderA_nwProject.nwx index a35421d0..aa42347e 100644 --- a/tests/reference/coreTools_ProjectBuilderA_nwProject.nwx +++ b/tests/reference/coreTools_ProjectBuilderA_nwProject.nwx @@ -1,5 +1,5 @@ - + Test Project A Jane Doe @@ -28,7 +28,7 @@ Main - + Novel diff --git a/tests/reference/coreTools_ProjectBuilderB_nwProject.nwx b/tests/reference/coreTools_ProjectBuilderB_nwProject.nwx index 0185bc6a..a749b6f5 100644 --- a/tests/reference/coreTools_ProjectBuilderB_nwProject.nwx +++ b/tests/reference/coreTools_ProjectBuilderB_nwProject.nwx @@ -1,5 +1,5 @@ - + Test Project B Jane Doe @@ -28,7 +28,7 @@ Main - + Novel diff --git a/tests/reference/fmtToDocX_SaveDocument_app.xml b/tests/reference/fmtToDocX_SaveDocument_app.xml index 59d08d55..43e25401 100644 --- a/tests/reference/fmtToDocX_SaveDocument_app.xml +++ b/tests/reference/fmtToDocX_SaveDocument_app.xml @@ -1,7 +1,7 @@ - 40 - novelWriter/2.6a3 + 41 + novelWriter/2.7b1 4035 21296 24964 diff --git a/tests/reference/fmtToDocX_SaveDocument_core.xml b/tests/reference/fmtToDocX_SaveDocument_core.xml index a46abe5d..3a0d34b6 100644 --- a/tests/reference/fmtToDocX_SaveDocument_core.xml +++ b/tests/reference/fmtToDocX_SaveDocument_core.xml @@ -1,10 +1,10 @@ - 2024-11-20T19:45:15 - 2024-11-20T19:45:15 + 2025-04-29T22:46:36 + 2025-04-29T22:46:36 lipsum.com Lorem Ipsum en_GB - 51 + 52 lipsum.com diff --git a/tests/reference/guiEditor_Main_Final_nwProject.nwx b/tests/reference/guiEditor_Main_Final_nwProject.nwx index e90fa531..e30c37ba 100644 --- a/tests/reference/guiEditor_Main_Final_nwProject.nwx +++ b/tests/reference/guiEditor_Main_Final_nwProject.nwx @@ -1,6 +1,6 @@ - - + + New Project Jane Doe @@ -28,7 +28,7 @@ Main - + Novel diff --git a/tests/reference/guiEditor_Main_Initial_nwProject.nwx b/tests/reference/guiEditor_Main_Initial_nwProject.nwx index 27e64dac..589fbc64 100644 --- a/tests/reference/guiEditor_Main_Initial_nwProject.nwx +++ b/tests/reference/guiEditor_Main_Initial_nwProject.nwx @@ -1,5 +1,5 @@ - + New Project Jane Doe @@ -28,7 +28,7 @@ Main - + Novel diff --git a/tests/reference/projectXML_ReadCurrent.json b/tests/reference/projectXML_ReadCurrent.json index 758a1f56..fe07c294 100644 --- a/tests/reference/projectXML_ReadCurrent.json +++ b/tests/reference/projectXML_ReadCurrent.json @@ -38,10 +38,10 @@ "metaAttr": { "expanded": false, "heading": "H1", - "charCount": 93, - "wordCount": 19, - "paraCount": 2, - "cursorPos": 119 + "charCount": 148, + "wordCount": 29, + "paraCount": 4, + "cursorPos": 178 }, "nameAttr": { "status": "sc24b8f", @@ -63,10 +63,10 @@ "metaAttr": { "expanded": false, "heading": "H0", - "charCount": 251, - "wordCount": 50, + "charCount": 233, + "wordCount": 47, "paraCount": 2, - "cursorPos": 277 + "cursorPos": 194 }, "nameAttr": { "status": "sf12341", @@ -138,10 +138,10 @@ "metaAttr": { "expanded": false, "heading": "H3", - "charCount": 2687, - "wordCount": 479, - "paraCount": 14, - "cursorPos": 67 + "charCount": 2999, + "wordCount": 530, + "paraCount": 16, + "cursorPos": 159 }, "nameAttr": { "status": "s90e6c9", @@ -166,7 +166,7 @@ "charCount": 548, "wordCount": 108, "paraCount": 3, - "cursorPos": 465 + "cursorPos": 650 }, "nameAttr": { "status": "s90e6c9", @@ -191,7 +191,7 @@ "charCount": 617, "wordCount": 101, "paraCount": 3, - "cursorPos": 310 + "cursorPos": 1182 }, "nameAttr": { "status": "s78ea90", @@ -216,7 +216,7 @@ "charCount": 1909, "wordCount": 346, "paraCount": 7, - "cursorPos": 0 + "cursorPos": 1940 }, "nameAttr": { "status": "sf24ce6", @@ -241,7 +241,7 @@ "charCount": 139, "wordCount": 28, "paraCount": 1, - "cursorPos": 188 + "cursorPos": 356 }, "nameAttr": { "status": "s90e6c9", @@ -266,10 +266,10 @@ "charCount": 189, "wordCount": 37, "paraCount": 1, - "cursorPos": 0 + "cursorPos": 237 }, "nameAttr": { - "status": "s90e6c9", + "status": "sd51c5b", "import": "ia857f0", "active": true } @@ -341,10 +341,10 @@ "charCount": 299, "wordCount": 55, "paraCount": 2, - "cursorPos": 104 + "cursorPos": 387 }, "nameAttr": { - "status": "s90e6c9", + "status": "s8ae72a", "import": "ia857f0", "active": true } @@ -416,11 +416,11 @@ "charCount": 49, "wordCount": 9, "paraCount": 1, - "cursorPos": 24 + "cursorPos": 23 }, "nameAttr": { "status": "sf12341", - "import": "icfb3a5", + "import": "i2d7a54", "active": true } }, @@ -441,11 +441,11 @@ "charCount": 55, "wordCount": 9, "paraCount": 1, - "cursorPos": 25 + "cursorPos": 31 }, "nameAttr": { "status": "sf12341", - "import": "i2d7a54", + "import": "i56be10", "active": true } }, @@ -491,11 +491,11 @@ "charCount": 76, "wordCount": 15, "paraCount": 1, - "cursorPos": 20 + "cursorPos": 111 }, "nameAttr": { "status": "sf12341", - "import": "i56be10", + "import": "i2d7a54", "active": true } }, @@ -516,11 +516,11 @@ "charCount": 115, "wordCount": 24, "paraCount": 1, - "cursorPos": 133 + "cursorPos": 0 }, "nameAttr": { "status": "sf12341", - "import": "icfb3a5", + "import": "i4a1d39", "active": true } }, @@ -541,11 +541,11 @@ "charCount": 28, "wordCount": 6, "paraCount": 1, - "cursorPos": 45 + "cursorPos": 62 }, "nameAttr": { "status": "sf12341", - "import": "i2d7a54", + "import": "icfb3a5", "active": true } }, @@ -624,13 +624,113 @@ "active": true } }, + { + "name": "Templates", + "itemAttr": { + "handle": "f4ed1ae756a1f", + "parent": null, + "root": "f4ed1ae756a1f", + "order": 5, + "type": "ROOT", + "class": "TEMPLATE", + "layout": "NO_LAYOUT" + }, + "metaAttr": { + "expanded": true, + "heading": "H0", + "charCount": 0, + "wordCount": 0, + "paraCount": 0, + "cursorPos": 0 + }, + "nameAttr": { + "status": "sf12341", + "import": "ia857f0", + "active": false + } + }, + { + "name": "Scene", + "itemAttr": { + "handle": "5aec885635c85", + "parent": "f4ed1ae756a1f", + "root": "f4ed1ae756a1f", + "order": 0, + "type": "FILE", + "class": "TEMPLATE", + "layout": "DOCUMENT" + }, + "metaAttr": { + "expanded": false, + "heading": "H3", + "charCount": 9, + "wordCount": 2, + "paraCount": 1, + "cursorPos": 78 + }, + "nameAttr": { + "status": "sf12341", + "import": "ia857f0", + "active": true + } + }, + { + "name": "Chapter", + "itemAttr": { + "handle": "2a60782759c6f", + "parent": "f4ed1ae756a1f", + "root": "f4ed1ae756a1f", + "order": 1, + "type": "FILE", + "class": "TEMPLATE", + "layout": "DOCUMENT" + }, + "metaAttr": { + "expanded": false, + "heading": "H2", + "charCount": 11, + "wordCount": 2, + "paraCount": 1, + "cursorPos": 81 + }, + "nameAttr": { + "status": "sf12341", + "import": "ia857f0", + "active": true + } + }, + { + "name": "Character Note", + "itemAttr": { + "handle": "5ee8aebcdebc9", + "parent": "f4ed1ae756a1f", + "root": "f4ed1ae756a1f", + "order": 2, + "type": "FILE", + "class": "TEMPLATE", + "layout": "NOTE" + }, + "metaAttr": { + "expanded": false, + "heading": "H1", + "charCount": 53, + "wordCount": 7, + "paraCount": 1, + "cursorPos": 75 + }, + "nameAttr": { + "status": "sf12341", + "import": "ia857f0", + "active": true + } + }, { "name": "Trash", "itemAttr": { "handle": "98acd8c76c93a", "parent": null, "root": "98acd8c76c93a", - "order": 5, + "order": 6, "type": "ROOT", "class": "TRASH", "layout": "NO_LAYOUT" diff --git a/tests/reference/projectXML_ReadLegacy10.nwx b/tests/reference/projectXML_ReadLegacy10.nwx index b94b21a1..cc0c7d88 100644 --- a/tests/reference/projectXML_ReadLegacy10.nwx +++ b/tests/reference/projectXML_ReadLegacy10.nwx @@ -1,5 +1,5 @@ - + Sample Project Jay Doh @@ -35,7 +35,7 @@ Main - + Novel diff --git a/tests/reference/projectXML_ReadLegacy11.nwx b/tests/reference/projectXML_ReadLegacy11.nwx index 9697b28a..0ea370fb 100644 --- a/tests/reference/projectXML_ReadLegacy11.nwx +++ b/tests/reference/projectXML_ReadLegacy11.nwx @@ -1,5 +1,5 @@ - + Sample Project Jay Doh @@ -35,7 +35,7 @@ Main - + Novel diff --git a/tests/reference/projectXML_ReadLegacy12.nwx b/tests/reference/projectXML_ReadLegacy12.nwx index c87a5b38..bac7fb3a 100644 --- a/tests/reference/projectXML_ReadLegacy12.nwx +++ b/tests/reference/projectXML_ReadLegacy12.nwx @@ -1,5 +1,5 @@ - + Sample Project Jay Doh @@ -35,7 +35,7 @@ Main - + Novel diff --git a/tests/reference/projectXML_ReadLegacy13.nwx b/tests/reference/projectXML_ReadLegacy13.nwx index 35036927..1fb96f5e 100644 --- a/tests/reference/projectXML_ReadLegacy13.nwx +++ b/tests/reference/projectXML_ReadLegacy13.nwx @@ -1,5 +1,5 @@ - + Sample Project Jay Doh @@ -35,7 +35,7 @@ Main - + Novel diff --git a/tests/reference/projectXML_ReadLegacy14.nwx b/tests/reference/projectXML_ReadLegacy14.nwx index d25ee3aa..3c763d27 100644 --- a/tests/reference/projectXML_ReadLegacy14.nwx +++ b/tests/reference/projectXML_ReadLegacy14.nwx @@ -1,5 +1,5 @@ - + Sample Project Jay Doh @@ -35,7 +35,7 @@ Main - + Novel diff --git a/tests/test_core/test_core_projectxml.py b/tests/test_core/test_core_projectxml.py index acd5d19c..3021c7c4 100644 --- a/tests/test_core/test_core_projectxml.py +++ b/tests/test_core/test_core_projectxml.py @@ -52,8 +52,8 @@ class MockProject: @pytest.fixture(scope="function", autouse=True) def mockVersion(monkeypatch): """Mock the version info to prevent diff from failing.""" - monkeypatch.setattr("novelwriter.core.projectxml.__version__", "2.0-rc1") - monkeypatch.setattr("novelwriter.core.projectxml.__hexversion__", "0x020000c1") + monkeypatch.setattr("novelwriter.core.projectxml.__version__", "2.7b1") + monkeypatch.setattr("novelwriter.core.projectxml.__hexversion__", "0x020700b1") return @@ -137,23 +137,23 @@ def testCoreProjectXML_ReadCurrent(monkeypatch, mockGUI, tstPaths, fncPath): assert xmlReader.state == XMLReadState.PARSED_OK assert xmlReader.xmlRoot == "novelWriterXML" assert xmlReader.xmlVersion == 0x0105 - assert xmlReader.xmlRevision == 4 - assert xmlReader.appVersion == "2.0-rc1" - assert xmlReader.hexVersion == 0x020000c1 + assert xmlReader.xmlRevision == 5 + assert xmlReader.appVersion == "2.7b1" + assert xmlReader.hexVersion == 0x020700b1 # Check loaded data assert data.name == "Sample Project" assert data.author == "Jane Smith" - assert data.saveCount == 5 - assert data.autoCount == 10 + assert data.saveCount == 2179 + assert data.autoCount == 285 assert data.editTime == 1000 - assert data.doBackup is True + assert data.doBackup is False assert data.language == "en_GB" assert data.spellCheck is True - assert data.spellLang == "en_GB" - assert data.initCounts == (954, 409) - assert data.currCounts == (954, 409) + assert data.spellLang is None + assert data.initCounts == (1016, 416, 5602, 2285) + assert data.currCounts == (1016, 416, 5602, 2285) assert data.getLastHandle("editor") == "636b6aa9b697b" assert data.getLastHandle("viewer") == "636b6aa9b697b" @@ -182,33 +182,36 @@ def testCoreProjectXML_ReadCurrent(monkeypatch, mockGUI, tstPaths, fncPath): assert data.itemStatus["s78ea90"].color == QColor(58, 180, 58) assert data.itemImport["ia857f0"].color == QColor(100, 100, 100) - assert data.itemImport["icfb3a5"].color == QColor(0, 122, 188) - assert data.itemImport["i2d7a54"].color == QColor(21, 0, 180) - assert data.itemImport["i56be10"].color == QColor(117, 0, 175) + assert data.itemImport["i4a1d39"].color == QColor(220, 138, 221) + assert data.itemImport["icfb3a5"].color == QColor(220, 138, 221) + assert data.itemImport["i2d7a54"].color == QColor(220, 138, 221) + assert data.itemImport["i56be10"].color == QColor(220, 138, 221) assert data.itemStatus["sf12341"].shape == nwStatusShape.SQUARE assert data.itemStatus["sf24ce6"].shape == nwStatusShape.SQUARE - assert data.itemStatus["sc24b8f"].shape == nwStatusShape.SQUARE - assert data.itemStatus["s90e6c9"].shape == nwStatusShape.SQUARE - assert data.itemStatus["sd51c5b"].shape == nwStatusShape.SQUARE - assert data.itemStatus["s8ae72a"].shape == nwStatusShape.SQUARE - assert data.itemStatus["s78ea90"].shape == nwStatusShape.SQUARE + assert data.itemStatus["sc24b8f"].shape == nwStatusShape.BARS_1 + assert data.itemStatus["s90e6c9"].shape == nwStatusShape.BARS_2 + assert data.itemStatus["sd51c5b"].shape == nwStatusShape.BARS_3 + assert data.itemStatus["s8ae72a"].shape == nwStatusShape.BARS_4 + assert data.itemStatus["s78ea90"].shape == nwStatusShape.STAR assert data.itemImport["ia857f0"].shape == nwStatusShape.SQUARE - assert data.itemImport["icfb3a5"].shape == nwStatusShape.SQUARE - assert data.itemImport["i2d7a54"].shape == nwStatusShape.SQUARE - assert data.itemImport["i56be10"].shape == nwStatusShape.SQUARE + assert data.itemImport["i4a1d39"].shape == nwStatusShape.BLOCK_1 + assert data.itemImport["icfb3a5"].shape == nwStatusShape.BLOCK_2 + assert data.itemImport["i2d7a54"].shape == nwStatusShape.BLOCK_3 + assert data.itemImport["i56be10"].shape == nwStatusShape.BLOCK_4 - assert data.itemStatus["sf12341"].count == 4 + assert data.itemStatus["sf12341"].count == 8 assert data.itemStatus["sf24ce6"].count == 2 assert data.itemStatus["sc24b8f"].count == 3 - assert data.itemStatus["s90e6c9"].count == 7 - assert data.itemStatus["sd51c5b"].count == 0 - assert data.itemStatus["s8ae72a"].count == 0 + assert data.itemStatus["s90e6c9"].count == 5 + assert data.itemStatus["sd51c5b"].count == 1 + assert data.itemStatus["s8ae72a"].count == 1 assert data.itemStatus["s78ea90"].count == 1 assert data.itemImport["ia857f0"].count == 5 - assert data.itemImport["icfb3a5"].count == 2 + assert data.itemImport["i4a1d39"].count == 1 + assert data.itemImport["icfb3a5"].count == 1 assert data.itemImport["i2d7a54"].count == 2 assert data.itemImport["i56be10"].count == 1 @@ -281,8 +284,8 @@ def testCoreProjectXML_ReadLegacy10(tstPaths, fncPath, mockGUI, mockRnd): assert data.language is None # Doesn't exist in 1.0 assert data.spellCheck is True assert data.spellLang is None # Doesn't exist in 1.0 - assert data.initCounts == (0, 0) - assert data.currCounts == (0, 0) + assert data.initCounts == (0, 0, 0, 0) + assert data.currCounts == (0, 0, 0, 0) assert data.getLastHandle("editor") is None # Dropped by conversion assert data.getLastHandle("viewer") is None # Dropped by conversion @@ -426,8 +429,8 @@ def testCoreProjectXML_ReadLegacy11(tstPaths, fncPath, mockGUI, mockRnd): assert data.language is None # Doesn't exist in 1.1 assert data.spellCheck is True assert data.spellLang is None # Doesn't exist in 1.1 - assert data.initCounts == (0, 0) - assert data.currCounts == (0, 0) + assert data.initCounts == (0, 0, 0, 0) + assert data.currCounts == (0, 0, 0, 0) assert data.getLastHandle("editor") is None # Dropped by conversion assert data.getLastHandle("viewer") is None # Dropped by conversion @@ -571,8 +574,8 @@ def testCoreProjectXML_ReadLegacy12(tstPaths, fncPath, mockGUI, mockRnd): assert data.language == "en_GB" assert data.spellCheck is True assert data.spellLang == "en_GB" - assert data.initCounts == (840, 376) - assert data.currCounts == (840, 376) + assert data.initCounts == (840, 376, 0, 0) + assert data.currCounts == (840, 376, 0, 0) assert data.getLastHandle("editor") is None # Dropped by conversion assert data.getLastHandle("viewer") is None # Dropped by conversion @@ -719,8 +722,8 @@ def testCoreProjectXML_ReadLegacy13(tstPaths, fncPath, mockGUI, mockRnd): assert data.language == "en_GB" assert data.spellCheck is True assert data.spellLang == "en_GB" - assert data.initCounts == (830, 376) - assert data.currCounts == (830, 376) + assert data.initCounts == (830, 376, 0, 0) + assert data.currCounts == (830, 376, 0, 0) assert data.getLastHandle("editor") is None # Dropped by conversion assert data.getLastHandle("viewer") is None # Dropped by conversion @@ -867,8 +870,8 @@ def testCoreProjectXML_ReadLegacy14(tstPaths, fncPath, mockGUI, mockRnd): assert data.language == "en_GB" assert data.spellCheck is True assert data.spellLang == "en_GB" - assert data.initCounts == (954, 409) - assert data.currCounts == (954, 409) + assert data.initCounts == (954, 409, 0, 0) + assert data.currCounts == (954, 409, 0, 0) assert data.getLastHandle("editor") is None # Dropped by conversion assert data.getLastHandle("viewer") is None # Dropped by conversion diff --git a/tests/test_core/test_core_tree.py b/tests/test_core/test_core_tree.py index 1c96f580..432ae8f7 100644 --- a/tests/test_core/test_core_tree.py +++ b/tests/test_core/test_core_tree.py @@ -436,7 +436,7 @@ def testCoreTree_OtherMethods(qtbot, monkeypatch, mockGUI, fncPath, mockRnd): ] # Refresh All - assert tree.sumWords() == (9, 0) + assert tree.sumCounts() == (9, 0, 40, 0) assert tree.model.root.count == 9 for node in tree.nodes.values(): From 53e60c7b59e885af858efd2893eb58dff1acdd3c Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Tue, 29 Apr 2025 23:31:25 +0200 Subject: [PATCH 05/29] Add character counts to session log --- novelwriter/core/sessions.py | 42 ++++++++++++++++++--------- tests/test_core/test_core_sessions.py | 14 +++++---- tests/test_core/test_core_storage.py | 8 +++-- 3 files changed, 43 insertions(+), 21 deletions(-) diff --git a/novelwriter/core/sessions.py b/novelwriter/core/sessions.py index 659b6f9e..6936ed99 100644 --- a/novelwriter/core/sessions.py +++ b/novelwriter/core/sessions.py @@ -79,29 +79,36 @@ class NWSessionLog: return False now = time() - iNovel, iNotes, _, _ = self._project.data.initCounts - cNovel, cNotes, _, _ = self._project.data.currCounts - iTotal = iNovel + iNotes - wDiff = cNovel + cNotes - iTotal + iWNovel, iWNotes, iCNovel, iCNotes = self._project.data.initCounts + cWNovel, cWNotes, cCNovel, cCNotes = self._project.data.currCounts + iWTotal = iWNovel + iWNotes + iCTotal = iCNovel + iCNotes + wDiff = cWNovel + cWNotes - iWTotal + cDiff = cCNovel + cCNotes - iCTotal sTime = now - self._start - logger.info("The session lasted %d sec and added %d words", int(sTime), wDiff) - if sTime < 300 and wDiff == 0: + logger.info( + "The session lasted %d sec and added %d words abd %d characters", + int(sTime), wDiff, cDiff + ) + if sTime < 300 and (wDiff == 0 or cDiff == 0): logger.info("Session too short, skipping log entry") return False try: if not sessFile.exists(): with open(sessFile, mode="w", encoding="utf-8") as fObj: - fObj.write(self.createInitial(iTotal)) + fObj.write(self.createInitial(iWTotal)) with open(sessFile, mode="a+", encoding="utf-8") as fObj: fObj.write(self.createRecord( start=formatTimeStamp(self._start), end=formatTimeStamp(now), - novel=cNovel, - notes=cNotes, - idle=round(idleTime) + novel=cWNovel, + notes=cWNotes, + idle=round(idleTime), + cnovel=cCNovel, + cnotes=cCNotes, )) except Exception: @@ -129,10 +136,19 @@ class NWSessionLog: data = json.dumps({"type": "initial", "offset": total}) return f"{data}\n" - def createRecord(self, start: str, end: str, novel: int, notes: int, idle: int) -> str: + def createRecord( + self, start: str, end: str, novel: int, notes: int, idle: int, + cnovel: int = 0, cnotes: int = 0, + ) -> str: """Low level function to create a log record.""" data = json.dumps({ - "type": "record", "start": start, "end": end, - "novel": novel, "notes": notes, "idle": idle, + "type": "record", + "start": start, + "end": end, + "novel": novel, + "notes": notes, + "cnovel": cnovel, + "cnotes": cnotes, + "idle": idle, }) return f"{data}\n" diff --git a/tests/test_core/test_core_sessions.py b/tests/test_core/test_core_sessions.py index 93b6942f..d601fcd7 100644 --- a/tests/test_core/test_core_sessions.py +++ b/tests/test_core/test_core_sessions.py @@ -43,8 +43,8 @@ def testCoreSessions_Main(monkeypatch, mockGUI, fncPath): assert isinstance(logFile, Path) # Set some mock word counts - project.data.setInitCounts(50, 60) - project.data.setCurrCounts(160, 150) + project.data.setInitCounts(50, 60, 500, 600) + project.data.setCurrCounts(160, 150, 1600, 1500) # The project init should already have created the session sessLog = project.session @@ -71,17 +71,19 @@ def testCoreSessions_Main(monkeypatch, mockGUI, fncPath): assert records[1]["type"] == "record" assert records[1]["novel"] == 160 assert records[1]["notes"] == 150 + assert records[1]["cnovel"] == 1600 + assert records[1]["cnotes"] == 1500 assert records[1]["idle"] == 1 # Should be rounded to full seconds # Adding another record without changing word count should do nothing - project.data.setInitCounts(160, 150) - project.data.setCurrCounts(160, 150) + project.data.setInitCounts(160, 150, 1600, 1500) + project.data.setCurrCounts(160, 150, 1600, 1500) assert sessLog.appendSession(1.6) is False assert len(list(sessLog.iterRecords())) == 2 # But adding when count has changed should - project.data.setInitCounts(160, 150) - project.data.setCurrCounts(270, 240) + project.data.setInitCounts(160, 150, 1600, 1500) + project.data.setCurrCounts(270, 240, 2700, 2400) sessLog._start -= 350.0 # Backdate the session start to allow logging assert sessLog.appendSession(1.6) is True records = list(sessLog.iterRecords()) diff --git a/tests/test_core/test_core_storage.py b/tests/test_core/test_core_storage.py index 7ce7deb3..6e006d43 100644 --- a/tests/test_core/test_core_storage.py +++ b/tests/test_core/test_core_storage.py @@ -435,8 +435,8 @@ def testCoreStorage_OldFormatConvert(monkeypatch, mockGUI, fncPath): sessLogOld.write_text(( "# Offset 150\n" "# Start Time End Time Novel Notes Idle\n" - "2021-02-02 02:02:02 2021-02-02 03:03:03 200 200 10\n" - "2021-03-03 03:03:03 2021-03-03 04:04:04 300 300 20\n" + "2021-02-02 02:02:02 2021-02-02 03:03:03 200 200 10\n" + "2021-03-03 03:03:03 2021-03-03 04:04:04 300 300 20\n" ), encoding="utf-8") assert sessLogOld.exists() is True @@ -496,6 +496,8 @@ def testCoreStorage_OldFormatConvert(monkeypatch, mockGUI, fncPath): "end": "2021-02-02 03:03:03", "novel": 200, "notes": 200, + "cnovel": 0, + "cnotes": 0, "idle": 10, } assert data[2] == { @@ -504,6 +506,8 @@ def testCoreStorage_OldFormatConvert(monkeypatch, mockGUI, fncPath): "end": "2021-03-03 04:04:04", "novel": 300, "notes": 300, + "cnovel": 0, + "cnotes": 0, "idle": 20, } From 6d2e9d8a04ef01b5c6088608d69c514117fe4fbf Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Tue, 29 Apr 2025 23:45:05 +0200 Subject: [PATCH 06/29] Refresh novel view on count method change --- novelwriter/gui/noveltree.py | 15 ++++++++++----- novelwriter/guimain.py | 4 +++- tests/test_gui/test_gui_guimain.py | 1 + tests/test_gui/test_gui_noveltree.py | 2 +- 4 files changed, 15 insertions(+), 7 deletions(-) diff --git a/novelwriter/gui/noveltree.py b/novelwriter/gui/noveltree.py index ac98cd83..eb07ddf9 100644 --- a/novelwriter/gui/noveltree.py +++ b/novelwriter/gui/noveltree.py @@ -80,6 +80,7 @@ class GuiNovelView(QWidget): # Function Mappings self.setActive = self.novelBar.setActive self.getSelectedHandle = self.novelTree.getSelectedHandle + self.refreshCurrentTree = self.novelBar.forceRefreshNovelTree return @@ -209,7 +210,7 @@ class GuiNovelToolBar(QWidget): # Refresh Button self.tbRefresh = NIconToolButton(self, iSz) self.tbRefresh.setToolTip(self.tr("Refresh")) - self.tbRefresh.clicked.connect(self._forceRefreshNovelTree) + self.tbRefresh.clicked.connect(self.forceRefreshNovelTree) # More Options Menu self.mMore = QMenu(self) @@ -274,7 +275,7 @@ class GuiNovelToolBar(QWidget): self.novelValue.updateTheme() self.tbNovel.setVisible(self.novelValue.count() > 1) - self._forceRefreshNovelTree() + self.forceRefreshNovelTree() return @@ -305,7 +306,7 @@ class GuiNovelToolBar(QWidget): self.aLastCol[colType].setChecked(True) self.novelView.novelTree.setLastColType(colType) if doRefresh: - self._forceRefreshNovelTree() + self.forceRefreshNovelTree() self.novelView.novelTree.resizeColumns() return @@ -323,11 +324,11 @@ class GuiNovelToolBar(QWidget): return ## - # Private Slots + # Public Slots ## @pyqtSlot() - def _forceRefreshNovelTree(self) -> None: + def forceRefreshNovelTree(self) -> None: """Rebuild the current tree.""" if tHandle := self.novelValue.handle: self.novelView.setCurrentNovel(tHandle) @@ -335,6 +336,10 @@ class GuiNovelToolBar(QWidget): self._refresh[tHandle] = False return + ## + # Private Slots + ## + @pyqtSlot(str) def _refreshNovelTree(self, tHandle: str) -> None: """Refresh or schedule refresh of a novel tree.""" diff --git a/novelwriter/guimain.py b/novelwriter/guimain.py index ee32c547..f946a32a 100644 --- a/novelwriter/guimain.py +++ b/novelwriter/guimain.py @@ -1048,8 +1048,10 @@ class GuiMain(QMainWindow): self.initMain() self.saveDocument() - if tree: + if tree and not theme: + # These are also updated by a theme refresh SHARED.project.tree.refreshAllItems() + self.novelView.refreshCurrentTree() if theme: SHARED.theme.loadTheme() diff --git a/tests/test_gui/test_gui_guimain.py b/tests/test_gui/test_gui_guimain.py index 24694ffe..6fda9b6a 100644 --- a/tests/test_gui/test_gui_guimain.py +++ b/tests/test_gui/test_gui_guimain.py @@ -184,6 +184,7 @@ def testGuiMain_UpdateTheme(qtbot, nwGUI): CONFIG.guiSyntax = "default_dark" mainTheme.loadTheme() mainTheme.loadSyntax() + nwGUI._processConfigChanges(False, True, False, False) nwGUI._processConfigChanges(True, True, True, True) syntax = SHARED.theme.syntaxTheme diff --git a/tests/test_gui/test_gui_noveltree.py b/tests/test_gui/test_gui_noveltree.py index 2da937bd..cd726e0a 100644 --- a/tests/test_gui/test_gui_noveltree.py +++ b/tests/test_gui/test_gui_noveltree.py @@ -93,7 +93,7 @@ def testGuiNovelView_Content(qtbot, monkeypatch, nwGUI, projPath, mockRnd): assert novelTree._getModel() is None # Reload - novelBar._forceRefreshNovelTree() + novelView.refreshCurrentTree() model = novelTree._getModel() assert isinstance(model, NovelModel) From 6818fdd00af224643ad6f1999f3aaaa6a894c297 Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Wed, 14 May 2025 22:48:30 +0200 Subject: [PATCH 07/29] Add lock threads cron job --- .github/workflows/lock_threads.yml | 35 ++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 .github/workflows/lock_threads.yml diff --git a/.github/workflows/lock_threads.yml b/.github/workflows/lock_threads.yml new file mode 100644 index 00000000..de74fa5e --- /dev/null +++ b/.github/workflows/lock_threads.yml @@ -0,0 +1,35 @@ +name: LockThreads + +on: + schedule: + - cron: "50 * * * *" + workflow_dispatch: + +permissions: + issues: write + pull-requests: write + +concurrency: + group: lock-threads + +jobs: + action: + runs-on: ubuntu-latest + steps: + - uses: dessant/lock-threads@v5 + with: + github-token: ${{ github.token }} + issue-inactive-days: 120 + issue-comment: > + This issue has been automatically locked since there has not been + any recent activity after it was closed. Please open a new issue + for related bugs or feature requests. + issue-lock-reason: "Resolved" + pr-inactive-days: 120 + pr-comment: > + This pull request thread has been automatically locked since there + has not been any recent activity after it was closed. Please open + an issue for any related bugs or feature requests. + pr-lock-reason: "Completed" + process-only: "issues. prs" + log-output: false From 44e901ab084e5561f06b0eb4f0ecdb34754c01dd Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Wed, 14 May 2025 22:50:01 +0200 Subject: [PATCH 08/29] Enable log output --- .github/workflows/lock_threads.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/lock_threads.yml b/.github/workflows/lock_threads.yml index de74fa5e..0fd09259 100644 --- a/.github/workflows/lock_threads.yml +++ b/.github/workflows/lock_threads.yml @@ -32,4 +32,4 @@ jobs: an issue for any related bugs or feature requests. pr-lock-reason: "Completed" process-only: "issues. prs" - log-output: false + log-output: true From c0ac50a501cfc8f6a3612355a92a60b0ca580c01 Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Wed, 14 May 2025 22:56:25 +0200 Subject: [PATCH 09/29] Fix lock threads job issues --- .github/workflows/lock_threads.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/lock_threads.yml b/.github/workflows/lock_threads.yml index 0fd09259..e201d61c 100644 --- a/.github/workflows/lock_threads.yml +++ b/.github/workflows/lock_threads.yml @@ -2,7 +2,7 @@ name: LockThreads on: schedule: - - cron: "50 * * * *" + - cron: "0 * * * *" workflow_dispatch: permissions: @@ -24,12 +24,12 @@ jobs: This issue has been automatically locked since there has not been any recent activity after it was closed. Please open a new issue for related bugs or feature requests. - issue-lock-reason: "Resolved" + issue-lock-reason: "resolved" pr-inactive-days: 120 pr-comment: > This pull request thread has been automatically locked since there has not been any recent activity after it was closed. Please open an issue for any related bugs or feature requests. - pr-lock-reason: "Completed" - process-only: "issues. prs" + pr-lock-reason: "resolved" + process-only: "issue, pr" log-output: true From 6c20847cc97efefefe210cb045a9ac707726f1a0 Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Wed, 14 May 2025 23:32:09 +0200 Subject: [PATCH 10/29] Change schedule to lock old threads once a day --- .github/workflows/lock_threads.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/lock_threads.yml b/.github/workflows/lock_threads.yml index e201d61c..b18f3387 100644 --- a/.github/workflows/lock_threads.yml +++ b/.github/workflows/lock_threads.yml @@ -2,8 +2,7 @@ name: LockThreads on: schedule: - - cron: "0 * * * *" - workflow_dispatch: + - cron: "0 1 * * *" permissions: issues: write From ca17263640797ee075e9225e499e7d24f2de57ac Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Thu, 15 May 2025 00:33:05 +0200 Subject: [PATCH 11/29] Disable lock thread comment to reduce notification spam --- .github/workflows/lock_threads.yml | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/.github/workflows/lock_threads.yml b/.github/workflows/lock_threads.yml index b18f3387..9c4a58fc 100644 --- a/.github/workflows/lock_threads.yml +++ b/.github/workflows/lock_threads.yml @@ -2,7 +2,7 @@ name: LockThreads on: schedule: - - cron: "0 1 * * *" + - cron: "35 0 * * *" permissions: issues: write @@ -19,16 +19,16 @@ jobs: with: github-token: ${{ github.token }} issue-inactive-days: 120 - issue-comment: > - This issue has been automatically locked since there has not been - any recent activity after it was closed. Please open a new issue - for related bugs or feature requests. + # issue-comment: > + # This issue has been automatically locked since there has not been + # any recent activity after it was closed. Please open a new issue + # for related bugs or feature requests. issue-lock-reason: "resolved" pr-inactive-days: 120 - pr-comment: > - This pull request thread has been automatically locked since there - has not been any recent activity after it was closed. Please open - an issue for any related bugs or feature requests. + # pr-comment: > + # This pull request thread has been automatically locked since there + # has not been any recent activity after it was closed. Please open + # an issue for any related bugs or feature requests. pr-lock-reason: "resolved" process-only: "issue, pr" log-output: true From a1aba9bed460efc03d28085b208ce320cb05a489 Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Thu, 15 May 2025 00:36:38 +0200 Subject: [PATCH 12/29] Run on a hourly schedule for now --- .github/workflows/lock_threads.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/lock_threads.yml b/.github/workflows/lock_threads.yml index 9c4a58fc..b1dd17a0 100644 --- a/.github/workflows/lock_threads.yml +++ b/.github/workflows/lock_threads.yml @@ -2,7 +2,7 @@ name: LockThreads on: schedule: - - cron: "35 0 * * *" + - cron: "40 * * * *" permissions: issues: write From 4c16e17d44176b9986b4cdab9acf08016bb8c7b0 Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Thu, 15 May 2025 12:42:55 +0200 Subject: [PATCH 13/29] Change lock threads schedule to run only every 3 days --- .github/workflows/lock_threads.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/lock_threads.yml b/.github/workflows/lock_threads.yml index b1dd17a0..cf2c9deb 100644 --- a/.github/workflows/lock_threads.yml +++ b/.github/workflows/lock_threads.yml @@ -2,7 +2,7 @@ name: LockThreads on: schedule: - - cron: "40 * * * *" + - cron: "14 3 */3 * *" permissions: issues: write @@ -19,10 +19,10 @@ jobs: with: github-token: ${{ github.token }} issue-inactive-days: 120 - # issue-comment: > - # This issue has been automatically locked since there has not been - # any recent activity after it was closed. Please open a new issue - # for related bugs or feature requests. + issue-comment: > + This issue has been automatically locked since there has not been + any recent activity after it was closed. Please open a new issue + for related bugs or feature requests. issue-lock-reason: "resolved" pr-inactive-days: 120 # pr-comment: > From 916039b42059fa03bdf44cdd4468fbe0fd37aa39 Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Sun, 18 May 2025 16:27:30 +0200 Subject: [PATCH 14/29] Allow display of character count in editor footer and status bar --- novelwriter/constants.py | 4 +++ novelwriter/core/item.py | 49 +++++++++++++++++++----------------- novelwriter/gui/doceditor.py | 48 +++++++++++++++++++++-------------- novelwriter/gui/statusbar.py | 20 ++++++++++----- novelwriter/guimain.py | 18 ++++++++++--- 5 files changed, 87 insertions(+), 52 deletions(-) diff --git a/novelwriter/constants.py b/novelwriter/constants.py index 16c5a082..c20b9c47 100644 --- a/novelwriter/constants.py +++ b/novelwriter/constants.py @@ -352,6 +352,10 @@ class nwLabels: nwStats.WORDS_TEXT: QT_TRANSLATE_NOOP("Stats", "Words in Text"), nwStats.WORDS_TITLE: QT_TRANSLATE_NOOP("Stats", "Words in Headings"), } + STATS_DISPLAY: Final[dict[str, str]] = { + nwStats.CHARS: QT_TRANSLATE_NOOP("Stats", "Characters: {0} ({1})"), + nwStats.WORDS: QT_TRANSLATE_NOOP("Stats", "Words: {0} ({1})"), + } BUILD_FMT: Final[dict[nwBuildFmt, str]] = { nwBuildFmt.ODT: QT_TRANSLATE_NOOP("Constant", "Open Document (.odt)"), nwBuildFmt.FODT: QT_TRANSLATE_NOOP("Constant", "Flat Open Document (.fodt)"), diff --git a/novelwriter/core/item.py b/novelwriter/core/item.py index 1dd17e47..e8f067a9 100644 --- a/novelwriter/core/item.py +++ b/novelwriter/core/item.py @@ -53,10 +53,10 @@ class NWItem: """ __slots__ = ( - "_active", "_charCount", "_class", "_cursorPos", "_expanded", - "_handle", "_heading", "_import", "_initCount", "_layout", "_name", + "_active", "_charCount", "_charInit", "_class", "_cursorPos", + "_expanded", "_handle", "_heading", "_import", "_layout", "_name", "_order", "_paraCount", "_parent", "_project", "_root", "_status", - "_type", "_wordCount", + "_type", "_wordCount", "_wordInit", ) def __init__(self, project: NWProject, handle: str) -> None: @@ -81,7 +81,8 @@ class NWItem: self._wordCount = 0 # Current word count self._paraCount = 0 # Current paragraph count self._cursorPos = 0 # Last cursor position - self._initCount = 0 # Initial word count + self._wordInit = 0 # Initial character count + self._charInit = 0 # Initial word count return @@ -170,7 +171,7 @@ class NWItem: @property def initCount(self) -> int: - return self._initCount + return self._wordInit if CONFIG.useCharCount else self._charInit @property def cursorPos(self) -> int: @@ -261,7 +262,8 @@ class NWItem: self._paraCount = 0 self._cursorPos = 0 - self._initCount = self._wordCount + self._wordInit = self._charCount + self._charInit = self._wordCount return True @@ -269,23 +271,24 @@ class NWItem: def duplicate(cls, source: NWItem, handle: str) -> NWItem: """Make a copy of an item.""" new = cls(source._project, handle) - new._name = source._name - new._parent = source._parent - new._root = source._root - new._order = source._order - new._type = source._type - new._class = source._class - new._layout = source._layout - new._status = source._status - new._import = source._import - new._active = source._active - new._expanded = source._expanded - new._heading = source._heading - new._charCount = source._charCount - new._wordCount = source._wordCount - new._paraCount = source._paraCount - new._cursorPos = source._cursorPos - new._initCount = source._initCount + new._name = source._name + new._parent = source._parent + new._root = source._root + new._order = source._order + new._type = source._type + new._class = source._class + new._layout = source._layout + new._status = source._status + new._import = source._import + new._active = source._active + new._expanded = source._expanded + new._heading = source._heading + new._charCount = source._charCount + new._wordCount = source._wordCount + new._paraCount = source._paraCount + new._cursorPos = source._cursorPos + new._wordInit = source._wordInit + new._charInit = source._charInit return new ## diff --git a/novelwriter/gui/doceditor.py b/novelwriter/gui/doceditor.py index 208db194..adbab414 100644 --- a/novelwriter/gui/doceditor.py +++ b/novelwriter/gui/doceditor.py @@ -55,7 +55,9 @@ from novelwriter import CONFIG, SHARED from novelwriter.common import ( decodeMimeHandles, fontMatcher, minmax, qtAddAction, qtLambda, transferCase ) -from novelwriter.constants import nwConst, nwKeyWords, nwShortcode, nwUnicode +from novelwriter.constants import ( + nwConst, nwKeyWords, nwLabels, nwShortcode, nwStats, nwUnicode, trStats +) from novelwriter.core.document import NWDocument from novelwriter.enum import ( nwChange, nwComment, nwDocAction, nwDocInsert, nwDocMode, nwItemClass, @@ -321,6 +323,7 @@ class GuiDocEditor(QPlainTextEdit): """ # Auto-Replace self._autoReplace.initSettings() + self.docFooter.initSettings() # Reload spell check and dictionaries SHARED.updateSpellCheckLanguage() @@ -1233,7 +1236,8 @@ class GuiDocEditor(QPlainTextEdit): """Process the word counter's finished signal.""" if self._docHandle and self._nwItem: logger.debug("Updating word count") - needsRefresh = wCount != self._nwItem.wordCount + mCount = cCount if CONFIG.useCharCount else wCount + needsRefresh = mCount != self._nwItem.mainCount self._nwItem.setCharCount(cCount) self._nwItem.setWordCount(wCount) self._nwItem.setParaCount(pCount) @@ -1241,7 +1245,7 @@ class GuiDocEditor(QPlainTextEdit): self._nwItem.notifyToRefresh() if not self.textCursor().hasSelection(): # Selection counter should take precedence (#2155) - self.docFooter.updateWordCount(wCount, False) + self.docFooter.updateMainCount(mCount, False) return @pyqtSlot() @@ -1254,7 +1258,7 @@ class GuiDocEditor(QPlainTextEdit): self._timerSel.start() else: self._timerSel.stop() - self.docFooter.updateWordCount(0, False) + self.docFooter.updateMainCount(0, False) return @pyqtSlot() @@ -1271,8 +1275,7 @@ class GuiDocEditor(QPlainTextEdit): def _updateSelCounts(self, cCount: int, wCount: int, pCount: int) -> None: """Update the counts on the counter's finished signal.""" if self._docHandle and self._nwItem: - logger.debug("User selected %d words", wCount) - self.docFooter.updateWordCount(wCount, True) + self.docFooter.updateMainCount(cCount if CONFIG.useCharCount else wCount, True) self._timerSel.stop() return @@ -3045,9 +3048,9 @@ class GuiDocEditFooter(QWidget): fPx = int(0.9*SHARED.theme.fontPixelSize) # Cached Translations + self.initSettings() self._trLineCount = self.tr("Line: {0} ({1})") - self._trWordCount = self.tr("Words: {0} ({1})") - self._trSelectCount = self.tr("Words: {0} selected") + self._trSelectCount = self.tr("Selected: {0}") # Main Widget Settings self.setContentsMargins(0, 0, 0, 0) @@ -3108,7 +3111,7 @@ class GuiDocEditFooter(QWidget): self.updateTheme() # Initialise Info - self.updateWordCount(0, False) + self.updateMainCount(0, False) logger.debug("Ready: GuiDocEditFooter") @@ -3118,6 +3121,13 @@ class GuiDocEditFooter(QWidget): # Methods ## + def initSettings(self) -> None: + """Apply user settings.""" + self._trMainCount = trStats(nwLabels.STATS_DISPLAY[ + nwStats.CHARS if CONFIG.useCharCount else nwStats.WORDS + ]) + return + def updateFont(self) -> None: """Update the font settings.""" self.setFont(SHARED.theme.guiFont) @@ -3162,7 +3172,7 @@ class GuiDocEditFooter(QWidget): self._tItem = SHARED.project.tree[self._docHandle] self.updateInfo() - self.updateWordCount(0, False) + self.updateMainCount(0, False) return @@ -3193,15 +3203,15 @@ class GuiDocEditFooter(QWidget): ) return - def updateWordCount(self, wCount: int, selection: bool) -> None: - """Update word counter information.""" - if selection and wCount: - wText = self._trSelectCount.format(f"{wCount:n}") + def updateMainCount(self, count: int, selection: bool) -> None: + """Update main counter information.""" + if selection and count: + text = self._trSelectCount.format(f"{count:n}") elif self._tItem: - wCount = self._tItem.wordCount - wDiff = wCount - self._tItem.initCount - wText = self._trWordCount.format(f"{wCount:n}", f"{wDiff:+n}") + count = self._tItem.mainCount + diff = count - self._tItem.initCount + text = self._trMainCount.format(f"{count:n}", f"{diff:+n}") else: - wText = self._trWordCount.format("0", "+0") - self.wordsText.setText(wText) + text = self._trMainCount.format("0", "+0") + self.wordsText.setText(text) return diff --git a/novelwriter/gui/statusbar.py b/novelwriter/gui/statusbar.py index 4f84b8c3..3239fd4a 100644 --- a/novelwriter/gui/statusbar.py +++ b/novelwriter/gui/statusbar.py @@ -33,7 +33,7 @@ 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.constants import nwConst, nwLabels, nwStats, trStats from novelwriter.extensions.modified import NClickableLabel from novelwriter.extensions.statusled import StatusLED @@ -108,11 +108,22 @@ class GuiMainStatus(QStatusBar): logger.debug("Ready: GuiMainStatus") + self.initSettings() self.updateTheme() self.clearStatus() return + def initSettings(self) -> None: + """Apply user settings.""" + if CONFIG.useCharCount: + self._trStatsCount = trStats(nwLabels.STATS_DISPLAY[nwStats.CHARS]) + self._trStatsTip = self.tr("Total character count (session change)") + else: + self._trStatsCount = trStats(nwLabels.STATS_DISPLAY[nwStats.WORDS]) + self._trStatsTip = self.tr("Total word count (session change)") + return + def clearStatus(self) -> None: """Reset all widgets on the status bar to default values.""" self.setRefTime(-1.0) @@ -173,11 +184,8 @@ class GuiMainStatus(QStatusBar): def setProjectStats(self, pWC: int, sWC: int) -> None: """Update the current project statistics.""" - self.statsText.setText(self.tr("Words: {0} ({1})").format(f"{pWC:n}", f"{sWC:+n}")) - if CONFIG.incNotesWCount: - self.statsText.setToolTip(self.tr("Project word count (session change)")) - else: - self.statsText.setToolTip(self.tr("Novel word count (session change)")) + self.statsText.setText(self._trStatsCount.format(f"{pWC:n}", f"{sWC:+n}")) + self.statsText.setToolTip(self._trStatsTip) return def updateTime(self, idleTime: float = 0.0) -> None: diff --git a/novelwriter/guimain.py b/novelwriter/guimain.py index f946a32a..a12383fe 100644 --- a/novelwriter/guimain.py +++ b/novelwriter/guimain.py @@ -1077,6 +1077,7 @@ class GuiMain(QMainWindow): self.projView.initSettings() self.novelView.initSettings() self.outlineView.initSettings() + self.mainStatus.initSettings() # Force update of word count self._lastTotalCount = 0 @@ -1265,11 +1266,20 @@ class GuiMain(QMainWindow): SHARED.project.updateCounts() if CONFIG.incNotesWCount: - iTotal = sum(SHARED.project.data.initCounts[:2]) - cTotal = sum(SHARED.project.data.currCounts[:2]) + if CONFIG.useCharCount: + iTotal = sum(SHARED.project.data.initCounts[2:]) + cTotal = sum(SHARED.project.data.currCounts[2:]) + else: + iTotal = sum(SHARED.project.data.initCounts[:2]) + cTotal = sum(SHARED.project.data.currCounts[:2]) else: - iTotal = SHARED.project.data.initCounts[0] - cTotal = SHARED.project.data.currCounts[0] + if CONFIG.useCharCount: + iTotal = SHARED.project.data.initCounts[2] + cTotal = SHARED.project.data.currCounts[2] + else: + iTotal = SHARED.project.data.initCounts[0] + cTotal = SHARED.project.data.currCounts[0] + self.mainStatus.setProjectStats(cTotal, cTotal - iTotal) return From 28c70dfc2ed868f0d8a460aadabc60893a774feb Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Sun, 18 May 2025 16:28:27 +0200 Subject: [PATCH 15/29] Update tests --- tests/test_gui/test_gui_doceditor.py | 5 ++--- tests/test_gui/test_gui_statusbar.py | 14 ++++++++++++++ 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/tests/test_gui/test_gui_doceditor.py b/tests/test_gui/test_gui_doceditor.py index 312c88bb..b198a71a 100644 --- a/tests/test_gui/test_gui_doceditor.py +++ b/tests/test_gui/test_gui_doceditor.py @@ -1957,7 +1957,7 @@ def testGuiEditor_WordCounters(qtbot, monkeypatch, nwGUI, projPath, ipsumText, m assert docEditor.docFooter.wordsText.text() == "Words: 0 (+0)" # Open a document and populate it - SHARED.project.tree[C.hSceneDoc]._initCount = 0 # type: ignore + SHARED.project.tree[C.hSceneDoc]._wordInit = 0 # type: ignore SHARED.project.tree[C.hSceneDoc]._wordCount = 0 # type: ignore assert nwGUI.openDocument(C.hSceneDoc) is True @@ -1981,7 +1981,6 @@ def testGuiEditor_WordCounters(qtbot, monkeypatch, nwGUI, projPath, ipsumText, m assert threadPool.objectID() == id(docEditor._wCounterDoc) docEditor._wCounterDoc.run() - # docEditor._updateDocCounts(cC, wC, pC) assert SHARED.project.tree[C.hSceneDoc]._charCount == cC # type: ignore assert SHARED.project.tree[C.hSceneDoc]._wordCount == wC # type: ignore assert SHARED.project.tree[C.hSceneDoc]._paraCount == pC # type: ignore @@ -1993,7 +1992,7 @@ def testGuiEditor_WordCounters(qtbot, monkeypatch, nwGUI, projPath, ipsumText, m assert threadPool.objectID() == id(docEditor._wCounterSel) docEditor._wCounterSel.run() - assert docEditor.docFooter.wordsText.text() == f"Words: {wC} selected" + assert docEditor.docFooter.wordsText.text() == f"Selected: {wC}" # qtbot.stop() diff --git a/tests/test_gui/test_gui_statusbar.py b/tests/test_gui/test_gui_statusbar.py index 7090f6f7..a4d5d91f 100644 --- a/tests/test_gui/test_gui_statusbar.py +++ b/tests/test_gui/test_gui_statusbar.py @@ -109,4 +109,18 @@ def testGuiStatusBar_Main(qtbot, monkeypatch, nwGUI, projPath, mockRnd): nwGUI._timeTick() assert status.statsText.text() == "Words: 11 (+11)" + # Switch to character count + CONFIG.useCharCount = True + status.initSettings() + with monkeypatch.context() as mp: + mp.setattr("novelwriter.guimain.time", lambda *a: 50.0) + CONFIG.incNotesWCount = True + nwGUI._lastTotalCount = 0 + nwGUI._timeTick() + assert status.statsText.text() == "Characters: 46 (+46)" + CONFIG.incNotesWCount = False + nwGUI._lastTotalCount = 0 + nwGUI._timeTick() + assert status.statsText.text() == "Characters: 40 (+40)" + # qtbot.stop() From c2059a870148c7520977f95d9988c8702a8af984 Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Sun, 18 May 2025 16:58:39 +0200 Subject: [PATCH 16/29] Notify the index when an item's class changes (#2330) --- novelwriter/core/index.py | 15 +++++++++++++++ novelwriter/core/item.py | 6 +++++- tests/test_core/test_core_index.py | 6 ++++++ 3 files changed, 26 insertions(+), 1 deletion(-) diff --git a/novelwriter/core/index.py b/novelwriter/core/index.py index 0a74cf7e..6720d171 100644 --- a/novelwriter/core/index.py +++ b/novelwriter/core/index.py @@ -131,6 +131,12 @@ class Index: self._novelExtra = extra return + def setItemClass(self, tHandle: str, itemClass: nwItemClass) -> None: + """Update the class for all tags of a handle.""" + logger.info("Updating class for '%s'", tHandle) + self._tagsIndex.updateClass(tHandle, itemClass.name) + return + ## # Public Methods ## @@ -854,6 +860,15 @@ class TagsIndex: x.get("name", "") for x in self._tags.values() if x.get("class", "") == className ] + def updateClass(self, tHandle: str, className: str) -> None: + """Update the class name of an item. This must be called when a + document moves to another class. + """ + for entry in self._tags.values(): + if entry.get("handle") == tHandle: + entry["class"] = className + return + ## # Pack/Unpack ## diff --git a/novelwriter/core/item.py b/novelwriter/core/item.py index e8f067a9..a8f2fbbd 100644 --- a/novelwriter/core/item.py +++ b/novelwriter/core/item.py @@ -435,7 +435,11 @@ class NWItem: """ if self._parent is not None: # Only update for child items - self.setClass(itemClass) + if itemClass != self._class: + self.setClass(itemClass) + if self._type == nwItemType.FILE: + # Notify the index of the class change + self._project.index.setItemClass(self._handle, itemClass) if self._layout == nwItemLayout.NO_LAYOUT: # If no layout is set, pick one diff --git a/tests/test_core/test_core_index.py b/tests/test_core/test_core_index.py index 723f8f9f..990a2206 100644 --- a/tests/test_core/test_core_index.py +++ b/tests/test_core/test_core_index.py @@ -1049,6 +1049,12 @@ def testCoreIndex_TagsIndex(): assert tagsIndex.tagClass("Tag3") == nwItemClass.PLOT.name assert tagsIndex.tagClass("Tag4") is None + # Change class of item + tagsIndex.updateClass("0000000000003", nwItemClass.WORLD.name) + assert tagsIndex.tagClass("Tag3") == nwItemClass.WORLD.name + tagsIndex.updateClass("0000000000003", nwItemClass.PLOT.name) + assert tagsIndex.tagClass("Tag3") == nwItemClass.PLOT.name + # Pack Data assert tagsIndex.packData() == content From bbcd11531a5220f28da7e8637602c6fc507b9659 Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Sun, 18 May 2025 17:10:32 +0200 Subject: [PATCH 17/29] Fix order of story structure columns in CSV export (#2313) --- novelwriter/gui/outline.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/novelwriter/gui/outline.py b/novelwriter/gui/outline.py index 3937ed53..efe20aea 100644 --- a/novelwriter/gui/outline.py +++ b/novelwriter/gui/outline.py @@ -784,7 +784,8 @@ class GuiOutlineTree(QTreeWidget): ): if novIdx.level != "H0" and (nwItem := SHARED.project.tree[tHandle]): refs = SHARED.project.index.getReferences(tHandle, sTitle) - story = {k: v for k, v in novIdx.comments.items() if k in sMatch}.values() + comments = dict(novIdx.comments.items()) + story = [comments.get(k, "") for k in sMatch] data.append([ novIdx.level, novIdx.title, From f2f2e6cb46933800632f5888b640669c93f3830d Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Sun, 18 May 2025 21:56:58 +0200 Subject: [PATCH 18/29] Extend completer for editor to also support comments --- novelwriter/gui/doceditor.py | 81 ++++++++++++++++++++++++++---------- 1 file changed, 60 insertions(+), 21 deletions(-) diff --git a/novelwriter/gui/doceditor.py b/novelwriter/gui/doceditor.py index adbab414..74ae1c71 100644 --- a/novelwriter/gui/doceditor.py +++ b/novelwriter/gui/doceditor.py @@ -3,15 +3,16 @@ novelWriter – GUI Document Editor ================================= File History: -Created: 2018-09-29 [0.0.1] GuiDocEditor -Created: 2019-04-22 [0.0.1] BackgroundWordCounter -Created: 2019-09-29 [0.2.1] GuiDocEditSearch -Created: 2020-04-25 [0.4.5] GuiDocEditHeader -Rewritten: 2020-06-15 [0.9] GuiDocEditSearch -Created: 2020-06-27 [0.10] GuiDocEditFooter -Rewritten: 2020-10-07 [1.0b3] BackgroundWordCounter -Created: 2023-11-06 [2.2b1] MetaCompleter -Created: 2023-11-07 [2.2b1] GuiDocToolBar +Created: 2018-09-29 [0.0.1] GuiDocEditor +Created: 2019-04-22 [0.0.1] BackgroundWordCounter +Created: 2019-09-29 [0.2.1] GuiDocEditSearch +Created: 2020-04-25 [0.4.5] GuiDocEditHeader +Rewritten: 2020-06-15 [0.9] GuiDocEditSearch +Created: 2020-06-27 [0.10] GuiDocEditFooter +Rewritten: 2020-10-07 [1.0b3] BackgroundWordCounter +Created: 2023-11-06 [2.2b1] MetaCompleter +Created: 2023-11-07 [2.2b1] GuiDocToolBar +Extended: 2025-05-18 [2.7rc1] CommandCompleter This file is a part of novelWriter Copyright (C) 2018 Veronica Berglyd Olsen and novelWriter contributors @@ -149,7 +150,7 @@ class GuiDocEditor(QPlainTextEdit): self._autoReplace = TextAutoReplace() # Completer - self._completer = MetaCompleter(self) + self._completer = CommandCompleter(self) self._completer.complete.connect(self._insertCompletion) # Create Custom Document @@ -1079,13 +1080,16 @@ class GuiDocEditor(QPlainTextEdit): if (block := self._qDocument.findBlock(pos)).isValid(): text = block.text() - if text.startswith("@") and added + removed == 1: + if text and text[0] in "@%" and added + removed == 1: # Only run on single character changes, or it will trigger # at unwanted times when other changes are made to the document cursor = self.textCursor() bPos = cursor.positionInBlock() if bPos > 0 and (viewport := self.viewport()): - show = self._completer.updateText(text, bPos) + if text[0] == "@": + show = self._completer.updateMetaText(text, bPos) + else: + show = self._completer.updateCommentText(text, bPos) point = self.cursorRect().bottomRight() self._completer.move(viewport.mapToGlobal(point)) self._completer.setVisible(show) @@ -2073,13 +2077,13 @@ class GuiDocEditor(QPlainTextEdit): return -class MetaCompleter(QMenu): - """GuiWidget: Meta Completer Menu +class CommandCompleter(QMenu): + """GuiWidget: Command Completer Menu This is a context menu with options populated from the user's - defined tags. It also helps to type the meta data keyword on a new - line starting with an @. The updateText function should be called on - every keystroke on a line starting with @. + defined tags and keys. It also helps to type the meta data keyword + on a new line starting with @ or %. The update functions should be + called on every keystroke on a line starting with @ or %. """ complete = pyqtSignal(int, int, str) @@ -2088,14 +2092,14 @@ class MetaCompleter(QMenu): super().__init__(parent=parent) return - def updateText(self, text: str, pos: int) -> bool: + def updateMetaText(self, text: str, pos: int) -> bool: """Update the menu options based on the line of text.""" self.clear() kw, sep, _ = text.partition(":") if pos <= len(kw): offset = 0 length = len(kw.rstrip()) - suffix = "" if sep else ":" + suffix = "" if sep else ": " options = list(filter( lambda x: x.startswith(kw.rstrip()), nwKeyWords.VALID_KEYS )) @@ -2108,7 +2112,7 @@ class MetaCompleter(QMenu): offset = tPos[index] if lookup else pos length = len(lookup) suffix = "" - options = list(filter( + options = sorted(filter( lambda x: lookup in x.lower(), SHARED.project.index.getClassTags( nwKeyWords.KEY_CLASS.get(kw.strip()) ) @@ -2117,13 +2121,48 @@ class MetaCompleter(QMenu): if not options: return False - for value in sorted(options): + for value in options: rep = value + suffix action = qtAddAction(self, value) action.triggered.connect(qtLambda(self._emitComplete, offset, length, rep)) return True + def updateCommentText(self, text: str, pos: int) -> bool: + """Update the menu options based on the line of text.""" + self.clear() + cmd, sep, _ = text.partition(":") + if pos <= len(cmd): + clean = text[1:].lstrip()[:6].lower() + if clean[:6] == "story.": + pre, _, key = cmd.partition(".") + offset = len(pre) + 1 + length = len(key) + suffix = "" if sep else ": " + options = sorted(filter( + lambda x: x.startswith(key.rstrip()), + SHARED.project.index.getStoryKeys(), + )) + elif pos < 12: + offset = 0 + length = len(cmd.rstrip()) + suffix = "" + options = list(filter( + lambda x: x.startswith(cmd.rstrip()), + ["%Synopsis: ", "%Short: ", "%Story", "%Note"], + )) + else: + return False + + if options: + for value in options: + rep = value + suffix + action = qtAddAction(self, rep.rstrip(":. ")) + action.triggered.connect(qtLambda(self._emitComplete, offset, length, rep)) + return True + + return False + ## # Events ## From f22f80dafd366a330f8a2d0e17d3e06f8501cb97 Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Sun, 18 May 2025 22:36:25 +0200 Subject: [PATCH 19/29] Add support for indexing and auto-completing story notes --- novelwriter/core/index.py | 11 ++++++++++- novelwriter/core/indexdata.py | 5 ++++- novelwriter/gui/doceditor.py | 13 +++++++++++-- 3 files changed, 25 insertions(+), 4 deletions(-) diff --git a/novelwriter/core/index.py b/novelwriter/core/index.py index 6720d171..fb357842 100644 --- a/novelwriter/core/index.py +++ b/novelwriter/core/index.py @@ -618,6 +618,10 @@ class Index: """Return all story structure keys.""" return self._itemIndex.allStoryKeys() + def getNoteKeys(self) -> set[str]: + """Return all note comment keys.""" + return self._itemIndex.allNoteKeys() + def novelStructure( self, rootHandle: str | None = None, activeOnly: bool = True ) -> Iterable[tuple[str, str, str, IndexHeading]]: @@ -920,11 +924,12 @@ class IndexCache: which provides lookup capabilities and caching for shared data. """ - __slots__ = ("story", "tags") + __slots__ = ("note", "story", "tags") def __init__(self, tagsIndex: TagsIndex) -> None: self.tags: TagsIndex = tagsIndex self.story: set[str] = set() + self.note: set[str] = set() return @@ -979,6 +984,10 @@ class ItemIndex: """Return all story structure keys.""" return self._cache.story.copy() + def allNoteKeys(self) -> set[str]: + """Return all note comment keys.""" + return self._cache.note.copy() + def allItemTags(self, tHandle: str) -> list[str]: """Get all tags set for headings of an item.""" if tHandle in self._items: diff --git a/novelwriter/core/indexdata.py b/novelwriter/core/indexdata.py index 131361f0..070558ce 100644 --- a/novelwriter/core/indexdata.py +++ b/novelwriter/core/indexdata.py @@ -316,6 +316,9 @@ class IndexHeading: case "story" if key: self._cache.story.add(key) self._comments[f"story.{key}"] = str(text) + case "note" if key: + self._cache.note.add(key) + self._comments[f"note.{key}"] = str(text) return def setTag(self, tag: str) -> None: @@ -395,7 +398,7 @@ class IndexHeading: self.addReference(tag, keyword) else: raise ValueError("Heading reference contains an invalid keyword") - elif key == "summary" or key.startswith("story"): + elif key == "summary" or key.startswith(("story", "note")): comment, _, kind = str(key).partition(".") self.setComment(comment, compact(kind), str(entry)) else: diff --git a/novelwriter/gui/doceditor.py b/novelwriter/gui/doceditor.py index 74ae1c71..0892bb71 100644 --- a/novelwriter/gui/doceditor.py +++ b/novelwriter/gui/doceditor.py @@ -2099,8 +2099,8 @@ class CommandCompleter(QMenu): if pos <= len(kw): offset = 0 length = len(kw.rstrip()) - suffix = "" if sep else ": " - options = list(filter( + suffix = "" if sep else ":" + options = sorted(filter( lambda x: x.startswith(kw.rstrip()), nwKeyWords.VALID_KEYS )) else: @@ -2143,6 +2143,15 @@ class CommandCompleter(QMenu): lambda x: x.startswith(key.rstrip()), SHARED.project.index.getStoryKeys(), )) + elif clean[:5] == "note.": + pre, _, key = cmd.partition(".") + offset = len(pre) + 1 + length = len(key) + suffix = "" if sep else ": " + options = sorted(filter( + lambda x: x.startswith(key.rstrip()), + SHARED.project.index.getNoteKeys(), + )) elif pos < 12: offset = 0 length = len(cmd.rstrip()) From ceadd52d1ade7b7a20bf37e7b759acb5654bc412 Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Sun, 18 May 2025 22:37:15 +0200 Subject: [PATCH 20/29] Update tests --- sample/content/636b6aa9b697b.nwd | 5 ++- sample/nwProject.nwx | 6 +-- tests/test_core/test_core_indexdata.py | 9 ++++ tests/test_gui/test_gui_doceditor.py | 60 +++++++++++++++++++++++++- 4 files changed, 74 insertions(+), 6 deletions(-) diff --git a/sample/content/636b6aa9b697b.nwd b/sample/content/636b6aa9b697b.nwd index 5fd4bb76..e3d4bfde 100644 --- a/sample/content/636b6aa9b697b.nwd +++ b/sample/content/636b6aa9b697b.nwd @@ -1,8 +1,8 @@ %%~name: Making a Scene %%~path: 6a2d6d5f4f401/636b6aa9b697b %%~kind: NOVEL/DOCUMENT -%%~hash: c057a5e9309b0e764c367b0fe9ab0607e3308622 -%%~date: Unknown/2025-04-08 20:10:33 +%%~hash: 1f3d98a6a27b4f9a7f2239fcd78f9e2263cd3b97 +%%~date: Unknown/2025-05-18 22:36:59 ### Making a Scene @pov: Jane @@ -11,6 +11,7 @@ @mention: Space %Story.Resolution: You can describe the scene structure with story comments. +%Note.Consistency: You can also make notes about things like consistency of the story. A scene is defined by a level three heading, like the one at the top of this page. The scene will be assigned to the chapter preceding it in the project tree. The scene document can be sorted after the chapter document, or as a child of the chapter. Both result in the same output in the end, so it is a matter of preference. diff --git a/sample/nwProject.nwx b/sample/nwProject.nwx index d20ba88e..b6fa62e7 100644 --- a/sample/nwProject.nwx +++ b/sample/nwProject.nwx @@ -1,6 +1,6 @@ - - + + Sample Project Jane Smith @@ -58,7 +58,7 @@ Chapter One - + Making a Scene diff --git a/tests/test_core/test_core_indexdata.py b/tests/test_core/test_core_indexdata.py index 9e1080f8..5bf45c8a 100644 --- a/tests/test_core/test_core_indexdata.py +++ b/tests/test_core/test_core_indexdata.py @@ -224,6 +224,14 @@ def testCoreIndexData_IndexHeading(): "story.crisis": "It exploded!", } + # Set Note Comment + head.setComment(nwComment.NOTE.name, "consitency", "Only explode once") + assert head.comments == { + "summary": "In the beginning ...", + "story.crisis": "It exploded!", + "note.consitency": "Only explode once", + } + # Set Tag head.setTag("Stuff") assert head.tag == "stuff" # Case insensitive @@ -240,6 +248,7 @@ def testCoreIndexData_IndexHeading(): "refs": {"stuff": "@object"}, "summary": "In the beginning ...", "story.crisis": "It exploded!", + "note.consitency": "Only explode once", } # Unpack KeyError diff --git a/tests/test_gui/test_gui_doceditor.py b/tests/test_gui/test_gui_doceditor.py index b198a71a..5bda2d82 100644 --- a/tests/test_gui/test_gui_doceditor.py +++ b/tests/test_gui/test_gui_doceditor.py @@ -1877,10 +1877,68 @@ def testGuiEditor_Completer(qtbot, nwGUI, projPath, mockRnd): qtbot.keyClick(completer, Qt.Key.Key_Down, delay=KEY_DELAY) qtbot.keyClick(completer, Qt.Key.Key_Return, delay=KEY_DELAY) qtbot.keyClick(completer, Qt.Key.Key_Escape, delay=KEY_DELAY) + qtbot.keyClick(docEditor, Qt.Key.Key_Return, delay=KEY_DELAY) assert docEditor.getText() == ( "### Scene One\n\n" "@char: Jane\n" - "@focus: John" + "@focus: John\n" + ) + + # Send keypresses to the completer object for a comment + qtbot.keyClick(docEditor, "%", delay=KEY_DELAY) + assert len(completer.actions()) == 4 + qtbot.keyClick(completer, Qt.Key.Key_Down, delay=KEY_DELAY) + qtbot.keyClick(completer, Qt.Key.Key_Return, delay=KEY_DELAY) + qtbot.keyClick(docEditor, Qt.Key.Key_Return, delay=KEY_DELAY) + assert docEditor.getText() == ( + "### Scene One\n\n" + "@char: Jane\n" + "@focus: John\n" + "%Synopsis: \n" + ) + + # Auto-complete story comment + SHARED.project.index._itemIndex._cache.story.add("Resolution") + qtbot.keyClick(docEditor, "%", delay=KEY_DELAY) + assert len(completer.actions()) == 4 + qtbot.keyClick(completer, Qt.Key.Key_Down, delay=KEY_DELAY) + qtbot.keyClick(completer, Qt.Key.Key_Down, delay=KEY_DELAY) + qtbot.keyClick(completer, Qt.Key.Key_Down, delay=KEY_DELAY) + qtbot.keyClick(completer, Qt.Key.Key_Return, delay=KEY_DELAY) + qtbot.keyClick(completer, ".", delay=KEY_DELAY) + assert len(completer.actions()) == 1 + qtbot.keyClick(completer, Qt.Key.Key_Down, delay=KEY_DELAY) + qtbot.keyClick(completer, Qt.Key.Key_Return, delay=KEY_DELAY) + qtbot.keyClick(docEditor, Qt.Key.Key_Return, delay=KEY_DELAY) + assert docEditor.getText() == ( + "### Scene One\n\n" + "@char: Jane\n" + "@focus: John\n" + "%Synopsis: \n" + "%Story.Resolution: \n" + ) + + # Auto-complete note comment + SHARED.project.index._itemIndex._cache.note.add("Consistency") + qtbot.keyClick(docEditor, "%", delay=KEY_DELAY) + assert len(completer.actions()) == 4 + qtbot.keyClick(completer, Qt.Key.Key_Down, delay=KEY_DELAY) + qtbot.keyClick(completer, Qt.Key.Key_Down, delay=KEY_DELAY) + qtbot.keyClick(completer, Qt.Key.Key_Down, delay=KEY_DELAY) + qtbot.keyClick(completer, Qt.Key.Key_Down, delay=KEY_DELAY) + qtbot.keyClick(completer, Qt.Key.Key_Return, delay=KEY_DELAY) + qtbot.keyClick(completer, ".", delay=KEY_DELAY) + assert len(completer.actions()) == 1 + qtbot.keyClick(completer, Qt.Key.Key_Down, delay=KEY_DELAY) + qtbot.keyClick(completer, Qt.Key.Key_Return, delay=KEY_DELAY) + qtbot.keyClick(docEditor, Qt.Key.Key_Return, delay=KEY_DELAY) + assert docEditor.getText() == ( + "### Scene One\n\n" + "@char: Jane\n" + "@focus: John\n" + "%Synopsis: \n" + "%Story.Resolution: \n" + "%Note.Consistency: \n" ) # qtbot.stop() From 47814e35cb1b5661148391ca4193dd9576be4875 Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Sun, 18 May 2025 22:46:54 +0200 Subject: [PATCH 21/29] Add support for including notes in manuscript --- novelwriter/assets/i18n/project_en_GB.json | 1 + novelwriter/core/buildsettings.py | 2 ++ novelwriter/core/docbuild.py | 1 + novelwriter/formats/tokenizer.py | 3 ++- novelwriter/gui/outline.py | 7 +++++++ novelwriter/tools/manuscript.py | 2 +- novelwriter/tools/manussettings.py | 4 ++++ 7 files changed, 18 insertions(+), 2 deletions(-) diff --git a/novelwriter/assets/i18n/project_en_GB.json b/novelwriter/assets/i18n/project_en_GB.json index b5288d04..237096f5 100644 --- a/novelwriter/assets/i18n/project_en_GB.json +++ b/novelwriter/assets/i18n/project_en_GB.json @@ -4,6 +4,7 @@ "Footnotes": "Footnotes", "Comment": "Comment", "Story Structure": "Story Structure", + "Note": "Note", "Notes": "Notes", "Tag": "Tag", "Point of View": "Point of View", diff --git a/novelwriter/core/buildsettings.py b/novelwriter/core/buildsettings.py index 13429e2c..0972dd71 100644 --- a/novelwriter/core/buildsettings.py +++ b/novelwriter/core/buildsettings.py @@ -79,6 +79,7 @@ SETTINGS_TEMPLATE: dict[str, tuple[type, T_BuildValue]] = { "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, ""), @@ -146,6 +147,7 @@ SETTINGS_LABELS = { "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 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"), diff --git a/novelwriter/core/docbuild.py b/novelwriter/core/docbuild.py index 4706e1d1..8e89095d 100644 --- a/novelwriter/core/docbuild.py +++ b/novelwriter/core/docbuild.py @@ -317,6 +317,7 @@ class NWBuildDocument: bldObj.setCommentType(nwComment.SYNOPSIS, self._build.getBool("text.includeSynopsis")) bldObj.setCommentType(nwComment.SHORT, self._build.getBool("text.includeSynopsis")) bldObj.setCommentType(nwComment.STORY, self._build.getBool("text.includeStory")) + bldObj.setCommentType(nwComment.NOTE, self._build.getBool("text.includeNotes")) if isinstance(bldObj, ToHtml): bldObj.setStyles(self._build.getBool("html.addStyles")) diff --git a/novelwriter/formats/tokenizer.py b/novelwriter/formats/tokenizer.py index 90300861..ba355a16 100644 --- a/novelwriter/formats/tokenizer.py +++ b/novelwriter/formats/tokenizer.py @@ -614,7 +614,8 @@ class Tokenizer(ABC): tStyle |= BlockFmt.JUSTIFY if cStyle in ( - nwComment.SYNOPSIS, nwComment.SHORT, nwComment.PLAIN, nwComment.STORY + nwComment.SYNOPSIS, nwComment.SHORT, nwComment.PLAIN, + nwComment.STORY, nwComment.NOTE, ): bStyle = COMMENT_STYLE[cStyle] tLine, tFmt = self._formatComment(bStyle, cKey, cText) diff --git a/novelwriter/gui/outline.py b/novelwriter/gui/outline.py index efe20aea..4ab18e50 100644 --- a/novelwriter/gui/outline.py +++ b/novelwriter/gui/outline.py @@ -751,9 +751,13 @@ class GuiOutlineTree(QTreeWidget): def _dumpNovelData(self, rootHandle: str | None) -> list[list[str | int]]: """Dump all novel data into a table.""" sLabel = SHARED.project.localLookup("Story Structure") + nLabel = SHARED.project.localLookup("Note") sKeys = sorted(SHARED.project.index.getStoryKeys()) + nKeys = sorted(SHARED.project.index.getNoteKeys()) sMatch = [f"story.{k}" for k in sKeys] + nMatch = [f"note.{k}" for k in nKeys] sHeaders = [f"{sLabel} ({k})" for k in sKeys] + nHeaders = [f"{nLabel} ({k})" for k in nKeys] data: list[list[str | int]] = [[ "H", @@ -777,6 +781,7 @@ class GuiOutlineTree(QTreeWidget): trConst(nwLabels.OUTLINE_COLS[nwOutline.MENTION]), trConst(nwLabels.OUTLINE_COLS[nwOutline.SYNOP]), *sHeaders, + *nHeaders, ]] for _, tHandle, sTitle, novIdx in SHARED.project.index.novelStructure( @@ -786,6 +791,7 @@ class GuiOutlineTree(QTreeWidget): refs = SHARED.project.index.getReferences(tHandle, sTitle) comments = dict(novIdx.comments.items()) story = [comments.get(k, "") for k in sMatch] + notes = [comments.get(k, "") for k in nMatch] data.append([ novIdx.level, novIdx.title, @@ -808,6 +814,7 @@ class GuiOutlineTree(QTreeWidget): ", ".join(refs[nwKeyWords.MENTION_KEY]), novIdx.synopsis, *story, + *notes, ]) return data diff --git a/novelwriter/tools/manuscript.py b/novelwriter/tools/manuscript.py index 8d4aca10..098a3f33 100644 --- a/novelwriter/tools/manuscript.py +++ b/novelwriter/tools/manuscript.py @@ -630,7 +630,7 @@ class _DetailsWidget(QWidget): self.listView.addTopLevelItem(item) for key in [ "text.includeSynopsis", "text.includeComments", "text.includeStory", - "text.includeKeywords", "text.includeBodyText", + "text.includeNotes", "text.includeKeywords", "text.includeBodyText", ]: sub = QTreeWidgetItem() sub.setText(0, build.getLabel(key)) diff --git a/novelwriter/tools/manussettings.py b/novelwriter/tools/manussettings.py index 855f64ea..521e08a0 100644 --- a/novelwriter/tools/manussettings.py +++ b/novelwriter/tools/manussettings.py @@ -974,12 +974,14 @@ class _FormattingTab(NScrollableForm): self.incSynopsis = NSwitch(self, height=iPx) self.incComments = NSwitch(self, height=iPx) self.incStory = NSwitch(self, height=iPx) + self.incNotes = NSwitch(self, height=iPx) self.incKeywords = NSwitch(self, height=iPx) self.addRow(self._build.getLabel("text.includeBodyText"), self.incBodyText) self.addRow(self._build.getLabel("text.includeSynopsis"), self.incSynopsis) self.addRow(self._build.getLabel("text.includeComments"), self.incComments) self.addRow(self._build.getLabel("text.includeStory"), self.incStory) + self.addRow(self._build.getLabel("text.includeNotes"), self.incNotes) self.addRow(self._build.getLabel("text.includeKeywords"), self.incKeywords) # Ignored Keywords @@ -1288,6 +1290,7 @@ class _FormattingTab(NScrollableForm): self.incSynopsis.setChecked(self._build.getBool("text.includeSynopsis")) self.incComments.setChecked(self._build.getBool("text.includeComments")) self.incStory.setChecked(self._build.getBool("text.includeStory")) + self.incNotes.setChecked(self._build.getBool("text.includeNotes")) self.incKeywords.setChecked(self._build.getBool("text.includeKeywords")) self.ignoredKeywords.setText(self._build.getStr("text.ignoredKeywords")) self.addNoteHead.setChecked(self._build.getBool("text.addNoteHeadings")) @@ -1387,6 +1390,7 @@ class _FormattingTab(NScrollableForm): self._build.setValue("text.includeSynopsis", self.incSynopsis.isChecked()) self._build.setValue("text.includeComments", self.incComments.isChecked()) self._build.setValue("text.includeStory", self.incStory.isChecked()) + self._build.setValue("text.includeNotes", self.incNotes.isChecked()) self._build.setValue("text.includeKeywords", self.incKeywords.isChecked()) self._build.setValue("text.ignoredKeywords", self.ignoredKeywords.text()) self._build.setValue("text.addNoteHeadings", self.addNoteHead.isChecked()) From 70ae2bd595eaa9ed62380e82e53133b9eb4c7ce5 Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Sun, 18 May 2025 22:47:01 +0200 Subject: [PATCH 22/29] Update tests --- tests/test_tools/test_tools_manussettings.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/test_tools/test_tools_manussettings.py b/tests/test_tools/test_tools_manussettings.py index e5756a18..b2d0479f 100644 --- a/tests/test_tools/test_tools_manussettings.py +++ b/tests/test_tools/test_tools_manussettings.py @@ -510,6 +510,8 @@ def testToolBuildSettings_FormatTextContent(qtbot, nwGUI): build.setValue("text.includeBodyText", False) build.setValue("text.includeSynopsis", False) build.setValue("text.includeComments", False) + build.setValue("text.includeStory", False) + build.setValue("text.includeNotes", False) build.setValue("text.includeKeywords", False) build.setValue("text.ignoredKeywords", "") @@ -530,6 +532,8 @@ def testToolBuildSettings_FormatTextContent(qtbot, nwGUI): assert fmtTab.incBodyText.isChecked() is False assert fmtTab.incSynopsis.isChecked() is False assert fmtTab.incComments.isChecked() is False + assert fmtTab.incStory.isChecked() is False + assert fmtTab.incNotes.isChecked() is False assert fmtTab.incKeywords.isChecked() is False assert fmtTab.ignoredKeywords.text() == "" @@ -539,6 +543,8 @@ def testToolBuildSettings_FormatTextContent(qtbot, nwGUI): fmtTab.incBodyText.setChecked(True) fmtTab.incSynopsis.setChecked(True) fmtTab.incComments.setChecked(True) + fmtTab.incStory.setChecked(True) + fmtTab.incNotes.setChecked(True) fmtTab.incKeywords.setChecked(True) fmtTab.addNoteHead.setChecked(True) @@ -554,6 +560,8 @@ def testToolBuildSettings_FormatTextContent(qtbot, nwGUI): assert build.getBool("text.includeBodyText") is True assert build.getBool("text.includeSynopsis") is True assert build.getBool("text.includeComments") is True + assert build.getBool("text.includeStory") is True + assert build.getBool("text.includeNotes") is True assert build.getBool("text.includeKeywords") is True assert build.getStr("text.ignoredKeywords") in ("@custom, @object", "@object, @custom") From 5fb45f7647a3855898fc287315dbb55755a5071c Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Sun, 18 May 2025 22:51:17 +0200 Subject: [PATCH 23/29] Rename notes to manuscript notes --- novelwriter/core/buildsettings.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/novelwriter/core/buildsettings.py b/novelwriter/core/buildsettings.py index 0972dd71..6129de05 100644 --- a/novelwriter/core/buildsettings.py +++ b/novelwriter/core/buildsettings.py @@ -147,7 +147,7 @@ SETTINGS_LABELS = { "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 Notes"), + "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"), From d033a3b23ef6b589d5bbd98ec70e6d21312fbe74 Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Mon, 19 May 2025 17:50:38 +0200 Subject: [PATCH 24/29] Bump version and update changelog --- CHANGELOG.md | 79 +++++++++++++++++++++++++++++++++++++++++ novelwriter/__init__.py | 6 ++-- sample/nwProject.nwx | 4 +-- 3 files changed, 84 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 229f3c63..69e81578 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,84 @@ # novelWriter Changelog +## Version 2.7 RC 1 [2025-05-19] + +### Release Notes + +This is a release candidate of the next release version, and is intended for testing purposes. +Please be careful when using this version on live writing projects, and make sure you take frequent +backups. + +### Detailed Changelog + +**New Features** + +* It is now possible to set character count as the main statistics displayed on the GUI instead of + word count. The setting is available under the "Appearance" section in Preferences. Issue #1657. + PR #2323. +* Comments can now be marked as "Manuscript Notes" with the `%Note.term:` syntax. The term is a + free form value that is displayed alongside the note when exported to the manuscript. Each term + is also added as a column in exports from the Outline View. Issue #1133. PR #2346. +* A completer drop down menu has been added to comments as well in the editor. It is triggered when + a new line starts with `%`. It will show the options of Synopsis, Short, Story and Note, and when + a period is added after the latter two, show a list of previously used Story and Note keys. + Issue #1784. PR #2290. + +**Improvements** + +* The dialogue colour of the Snazzy Light syntax theme has changed from yellow to blue, which + should make it easier to read. PR #2336. +* The Snazzy Light theme has also been added as a GUI theme. PR #2334. +* The auto-generated title page for new projects should now make a little more sense in terms of + default values filled in for the author. PR #2333. +* The new project form on the Welcome dialog now remembers the previous author name filled in, so + there should be no need to type it again every time. PR #2333. +* Project name and author name values are now also used for new example projects. PR #2333. +* The narrator break symbol settings for dialogue have been changed from a free text field to a + dropdown list of dashes. This helps to avoid misunderstandings where the field was populated with + other symbols, like quote symbols (see #2320). Issue #2324. PR #2327. +* A splash screen is now shown at startup, providing some information about the initialisation + steps the startup script is running before the GUI is launched. Since loading system fonts can be + quote heavy, this at least gives some user feedback to users running on systems where there is a + significant delay. In particular, there is a long delay when loading fonts with poor Unicode + support, in which case the Qt library will look for replacement glyphs when the symbols are first + encountered. Previously, the delay happened when the Welcome dialog was being drawn, since this + is the first time a non-Ascii Unicode character is used. Now these Unicode characters are probed + when the config is loaded, with progress output on the splash screen. Issue #2315. PR #2316. + +**Bugfixes** + +* Moving a note between root folders of a different type will now update the class the tags defined + within the note are associated with. This association is used to populate the tag auto-completion + in the editor. Issue #2330. PR #2343. +* The story structure comments now appear under the correct column when exported to CSV from the + Outline View. Issue #2313. PR #2343. +* A new unique ID is now assigned to new example projects. Creating multiple example projects would + previously retain the original ID, causing the Welcome dialog to override them rather than list + them as unique projects. PR #2333. +* In Qt6, or probably in PyQt6, the datetime type from Python was truncated to a date variable when + localising it. This has been fixed by explicitly converting the Python datetime to a QDateTime + before passing it to the PyQt wrapper. This is probably a bug in PyQt6. Issue #2325. PR #2326. +* A missing Remix icon for project copy has been added. Issue #2314. PR #2317. + +**Accessibility** + +* Trees and lists on the GUI have been assigned names so that screen readers can more easily + identify them. Switches on the GUI have also been associated with their labels so the screen + reader can pick up the label text. Icons on the project tree also have an accessibility label. + Issues #2106 and #2107. PR #2337. +* Switches should now show a highlighted border when they have focus, and the toggle animation also + now works properly when a switch is toggled using the keyboard. This should improve keyboard + navigation. PR #2337. +* The default status and importance labels for new projects now use shapes for icons, to make the + defaults more accessible for people with reduced colour vision. Issue #2332. PR #2333. + +**Code Improvements** + +* New tests have been added for the custom data models, using the Qt model tester framework that + ensures the models have all the virtual methods implemented. PR #2331. + +---- + ## Version 2.7 Beta 1 [2025-04-20] ### Release Notes diff --git a/novelwriter/__init__.py b/novelwriter/__init__.py index 0e0bb87b..3dfd1bd4 100644 --- a/novelwriter/__init__.py +++ b/novelwriter/__init__.py @@ -49,9 +49,9 @@ __license__ = "GPLv3" __author__ = "Veronica Berglyd Olsen" __maintainer__ = "Veronica Berglyd Olsen" __email__ = "code@vkbo.net" -__version__ = "2.7b1" -__hexversion__ = "0x020700b1" -__date__ = "2025-04-20" +__version__ = "2.7rc1" +__hexversion__ = "0x020700c1" +__date__ = "2025-05-19" __status__ = "Stable" __domain__ = "novelwriter.io" diff --git a/sample/nwProject.nwx b/sample/nwProject.nwx index b6fa62e7..feffb165 100644 --- a/sample/nwProject.nwx +++ b/sample/nwProject.nwx @@ -1,6 +1,6 @@ - - + + Sample Project Jane Smith From cdb8536e8df4a67a063007a1c42575f931067914 Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Mon, 19 May 2025 21:42:47 +0200 Subject: [PATCH 25/29] Update project settings and scripts after release --- pyproject.toml | 2 +- setup/make_pip.sh | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 0804a98c..ece574a8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [build-system] -requires = ["setuptools"] +requires = ["setuptools >= 77.0.3"] build-backend = "setuptools.build_meta" [project] diff --git a/setup/make_pip.sh b/setup/make_pip.sh index 25da2237..4c3eb846 100755 --- a/setup/make_pip.sh +++ b/setup/make_pip.sh @@ -49,5 +49,5 @@ echo " Done!" echo "================================================================================" echo "" echo " To upload packages to PyPi, run:" -echo " twine upload dist/*" +echo " python3 -m twine upload dist/*" echo "" From 84e5ef2dcf5d976af4e949d85c50230afc00887a Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Mon, 19 May 2025 21:42:59 +0200 Subject: [PATCH 26/29] Update base translation file --- i18n/nw_base.ts | 2435 +++++++++++++++++++++++++---------------------- 1 file changed, 1291 insertions(+), 1144 deletions(-) diff --git a/i18n/nw_base.ts b/i18n/nw_base.ts index 4b2dff1f..eb3c5d62 100644 --- a/i18n/nw_base.ts +++ b/i18n/nw_base.ts @@ -4,277 +4,287 @@ Builds - + Document Filters - + Novel Documents - + Project Notes - + Inactive Documents - + Headings - + Partition Format - + Chapter Format - + Unnumbered Format - + Scene Format - + Alt. Scene Format - + Section Format - + Title Styling - + Partition Styling - + Chapter Styling - + Scene Styling - + Text Content - + Include Synopsis - + Include Comments - - - Include Keywords - - - - - Include Body Text - - - - - Ignore These Keywords - - - - - Add Titles for Notes - - - - - Text Format - - - - - Text Font - - - Line Height + Include Story Structure - Justify Text Margins + Include Manuscript Notes - Replace Unicode Characters + Include Keywords - Replace Tabs with Spaces + Include Body Text - Preserve Hard Line Breaks + Ignore These Keywords - Apply Dialogue Highlighting + Add Titles for Notes - First Line Indent + Text Format - Enable Indent + Text Font - Indent Width + Line Height - Indent First Paragraph + Justify Text Margins + + + + + Replace Unicode Characters - Text Margins + Replace Tabs with Spaces - Title and Partition + Preserve Hard Line Breaks - Heading 1 and Chapter - - - - - Heading 2 and Scene + Apply Dialogue Highlighting - Heading 3 and Section + First Line Indent - Heading 4 + Enable Indent - Text Paragraph + Indent Width - Scene Separator + Indent First Paragraph - Page Layout + Text Margins - Unit + Title and Partition - Page Size + Heading 1 and Chapter - Page Margins + Heading 2 and Scene + + + + + Heading 3 and Section - Document Style + Heading 4 - Page Header + Text Paragraph - Page Counter Offset - - - - - Add Colours to Headings + Scene Separator - Increase Size of Headings + Page Layout - Bold Headings + Unit + + + + + Page Size - HTML Options - - - - - Add CSS Styles + Page Margins + Document Style + + + + + Page Header + + + + + Page Counter Offset + + + + + Add Colours to Headings + + + + + Increase Size of Headings + + + + + Bold Headings + + + + + HTML Options + + + + + Add CSS Styles + + + + Preserve Tab Characters @@ -282,72 +292,72 @@ Common - + in the future - + just now - + a minute ago - + {0} minutes ago - + an hour ago - + {0} hours ago - + a day ago - + {0} days ago - + a week ago - + {0} weeks ago - + a month ago - + {0} months ago - + a year ago - + {0} years ago @@ -355,559 +365,625 @@ Constant - - + + Title - + Heading 1 (Partition) - + Heading 2 (Chapter) - + Heading 3 (Scene) - + Heading 4 (Section) - + Text Paragraph - + Scene Separator + + - - + None - + Novel - - + + Plot - - + + Characters - - + + Locations - - + + Timeline - - + + Objects - - + + Entities - - - + + + Custom - + Archive - + Templates - + Trash - - + + Novel Document - - + + Project Note - + Root Folder - + Folder - + Novel Title Page - + Novel Chapter - + Novel Scene - + Novel Section - + Active - + Inactive - + Tag - + Point of View - - + + Focus - + Story - + Mentions - + Level - + Document - + Line - + Status - + Chars - + Words - + Pars - + POV - + Synopsis - + Open Document (.odt) - + Flat Open Document (.fodt) - + Microsoft Word Document (.docx) - + HTML 5 (.html) - + novelWriter Markup (.txt) - + Standard Markdown (.md) - + Extended Markdown (.md) - + Portable Document Format (.pdf) - + JSON + HTML 5 (.json) - + JSON + novelWriter Markup (.json) - + Square - + Triangle - + Nabla - + Diamond - + Pentagon - + Hexagon - + Star - + Pacman - + 1/4 Circle - + Half Circle - + 3/4 Circle - + Full Circle - + 1 Bar - + 2 Bars - + 3 Bars - + 4 Bars - + 1 Block - + 2 Blocks - + 3 Blocks - + 4 Blocks - + Text files - + Markdown files - + novelWriter files - + CSV files - + All files - + Millimetres - + Centimetres - + Inches - + A4 - + A5 - + A6 - + US Legal - + US Letter - - Straight single quotation mark + + Theme Colours - - Straight double quotation mark + + Foreground Colour - - Left single quotation mark + + Faded Colour - - Right single quotation mark + + Red - - Single low-9 quotation mark + + Orange - - Single high-reversed-9 quotation mark + + Yellow - - Left double quotation mark + + Green - - Right double quotation mark + + Aqua - - Double low-9 quotation mark + + Blue - - Double high-reversed-9 quotation mark - - - - - Double low-reversed-9 quotation mark - - - - - Single left-pointing angle quotation mark - - - - - Single right-pointing angle quotation mark - - - - - Double left-pointing angle quotation mark - - - - - Double right-pointing angle quotation mark - - - - - Left corner bracket - - - - - Right corner bracket - - - - - Left white corner bracket + + Purple + Straight single quotation mark + + + + + Straight double quotation mark + + + + + Left single quotation mark + + + + + Right single quotation mark + + + + + Single low-9 quotation mark + + + + + Single high-reversed-9 quotation mark + + + + + Left double quotation mark + + + + + Right double quotation mark + + + + + Double low-9 quotation mark + + + + + Double high-reversed-9 quotation mark + + + + + Double low-reversed-9 quotation mark + + + + + Single left-pointing angle quotation mark + + + + + Single right-pointing angle quotation mark + + + + + Double left-pointing angle quotation mark + + + + + Double right-pointing angle quotation mark + + + + + Left corner bracket + + + + + Right corner bracket + + + + + Left white corner bracket + + + + Right white corner bracket + + + Short dash + + + + + Long dash + + + + + Horizontal bar + + GuiAbout - + About novelWriter - + This application is licenced under {0} - + Credits @@ -915,36 +991,41 @@ GuiBuildSettings - + Manuscript Build Settings - + Name - + General - + Selection - + Headings - + Formatting + + + Do you want to save your changes to '{0}'? + + GuiDictionaries @@ -954,42 +1035,42 @@ - + Download a dictionary from one of the links, and add it below. - + Add Dictionary - + Dictionary install location - + Additional dictionaries found: {0} - + Free or Libre Office extension - + Browse Files - + Could not process dictionary file - + Added: {0} [{1}B] @@ -997,50 +1078,40 @@ GuiDocEditFooter - + Line: {0} ({1}) - - Words: {0} ({1}) - - - - - Words: {0} selected - - - - - Status + + Selected: {0} GuiDocEditHeader - + Toggle Tool Bar - + Outline - + Search - + Toggle Focus Mode - + Close @@ -1048,62 +1119,62 @@ GuiDocEditSearch - + Search for - + Replace with - + Search - + Case Sensitive - + Whole Words Only - + RegEx Mode - + Loop Search - + Search Next File - + Preserve Case - + Close Search - + Find in current document - + Find and replace in current document @@ -1111,132 +1182,132 @@ GuiDocEditor - + Opened Document: {0} - + This document has been changed outside of novelWriter while it was open. Overwrite the file on disk? - + Could not save document. - + Saved Document: {0} - + Spell checking requires the package PyEnchant. It does not appear to be installed. - + Spell check complete - + Document Details - + Created: {0} - + Updated: {0} - + File Location: {0} - + Set as Document Name - + Open URL - + Follow Tag - + Create Note for Tag - + Cut - + Copy - + Paste - + Select All - + Select Word - + Select Paragraph - + Spelling Suggestion(s) - + No Suggestions - + Ignore Word - + Add Word to Dictionary - + Please select some text before calling replace quotes. - + Do you want to create a new project note for the tag '{0}'? @@ -1259,7 +1330,7 @@ - + Move merged items to Trash @@ -1282,27 +1353,27 @@ - + Split on Heading Level 1 (Partition) - + Split up to Heading Level 2 (Chapter) - + Split up to Heading Level 3 (Scene) - + Split up to Heading Level 4 (Section) - + Split into a new folder @@ -1320,52 +1391,52 @@ GuiDocToolBar - + Markdown Bold - + Markdown Italic - + Markdown Strikethrough - + Shortcode Bold - + Shortcode Italic - + Shortcode Strikethrough - + Shortcode Underline - + Shortcode Highlight - + Shortcode Superscript - + Shortcode Subscript @@ -1373,27 +1444,27 @@ GuiDocViewFooter - + Show/Hide Viewer Panel - + Comments - + Show Comments - + Synopsis - + Show Synopsis Comments @@ -1401,32 +1472,32 @@ GuiDocViewHeader - + Outline - + Go Backward - + Go Forward - + Open in Editor - + Reload - + Close @@ -1434,27 +1505,27 @@ GuiDocViewer - + An error occurred while generating the preview. - + Copy - + Select All - + Select Word - + Select Paragraph @@ -1462,12 +1533,12 @@ GuiDocViewerPanel - + Hide Inactive Tags - + References @@ -1475,12 +1546,12 @@ GuiEditLabel - + Item Label - + Label @@ -1488,22 +1559,22 @@ GuiItemDetails - + Label - + Status - + Class - + Usage @@ -1516,22 +1587,22 @@ - + Insert Lorem Ipsum Text - + Number of paragraphs - + Randomise order - + Insert @@ -1539,103 +1610,103 @@ GuiMain - + novelWriter is ready ... - + You are now running novelWriter version {0}. - + Please check the {0}release notes{1} for further details. - + Close the current project? - - + + Changes are saved automatically. - + Backup the current project? - + The project is already open by another instance of novelWriter, and is therefore locked. Override lock and continue anyway? - + Note: If the program or the computer previously crashed, the lock can safely be overridden. However, overriding it is not recommended if the project is open in another instance of novelWriter. Doing so may corrupt the project. - + The project was locked by the computer '{0}' ({1} {2}), last active on {3}. - + The project index is outdated or broken. Rebuilding index. - + Import File - + Could not read file. The file must be an existing text file. - + Please open a document to import the text file into. - + Importing the file will overwrite the current content of the document. Do you want to proceed? - + Indexing completed in {0} ms - + The project index has been successfully rebuilt. - + Could not initialise the dialog. - + Do you want to exit novelWriter? - + Some changes will not be applied until novelWriter has been restarted. - + Could not find the reference for tag '{0}'. It either doesn't exist, or the index is out of date. The index can be updated from the Tools menu, or by pressing {1}. @@ -1643,652 +1714,652 @@ GuiMainMenu - + &Project - + Create or Open Project - + Save Project - + Close Project - + Project Settings - + Novel Details - + Rename Item - + Delete Item - + Empty Trash - + Exit - + &Document - + Open Document - + Save Document - + Close Document - + View Document - + Close Document View - + Show File Details - + Import Text from File - + &Edit - + Undo - + Redo - + Cut - + Copy - + Paste - + Select All - + Select Paragraph - + &View - + Go to Tree View - + Go to Document - + Go to Outline - + Navigate Backward - + Navigate Forward - + Focus Mode - + Full Screen Mode - + &Insert - + Dashes - + Short Dash - + Long Dash - + Horizontal Bar - + Figure Dash - + Quote Marks - + Left Single Quote - + Right Single Quote - + Left Double Quote - + Right Double Quote - + Alternative Apostrophe - + General Punctuation - + Ellipsis - + Prime - + Double Prime - + White Spaces - + Non-Breaking Space - + Thin Space - + Thin Non-Breaking Space - + Other Symbols - + List Bullet - + Hyphen Bullet - + Flower Mark - + Per Mille - + Degree Symbol - + Minus Sign - + Times Sign - + Division Sign - + Tags and References - + Special Comments - + Synopsis Comment - + Short Description Comment - + Word/Character Count - + Breaks and Vertical Space - + Page Break - + Forced Line Break - + Vertical Space (Single) - + Vertical Space (Multi) - + Placeholder Text - + Footnote - + &Format - + Bold - + Italic - + Strikethrough - + Wrap Double Quotes - + Wrap Single Quotes - + More Formats ... - + Bold (Shortcode) - + Italics (Shortcode) - + Strikethrough (Shortcode) - + Underline - + Highlight - + Superscript - + Subscript - + Novel Title - + Unnumbered Chapter - + Alternative Scene - + Align Left - + Align Centre - + Align Right - + Indent Left - + Indent Right - + Toggle Comment - + Toggle Ignore Text - + Remove Block Format - + Replace Straight Single Quotes - + Replace Straight Double Quotes - + Remove In-Paragraph Breaks - + &Search - + Find - + Replace - + Find Next - + Find Previous - + Replace Next - + Find in Project - + &Tools - + Check Spelling - + Spell Check Language - + Default - + Re-Run Spell Check - + Project Word List - + Add Dictionaries - + Rebuild Index - + Backup Project - + Build Manuscript - + Writing Statistics - + Preferences - + &Help - + About novelWriter - - About Qt5 + + About Qt - + User Manual (Online) - + User Manual (PDF) - + Report an Issue (GitHub) - + Ask a Question (GitHub) - + The novelWriter Website @@ -2296,39 +2367,34 @@ GuiMainStatus - - + + None - + Editor - + Project - + Session Time - - Words: {0} ({1}) + + Total character count (session change) - - Project word count (session change) - - - - - Novel word count (session change) + + Total word count (session change) @@ -2340,101 +2406,106 @@ - + Add New Build - + Delete Selected Build - + Duplicate Selected Build - + Edit Selected Build - + Builds - + Details - + Outline - + Preview - + Print - + Build - + Close - + Show Page Breaks - - + + My Manuscript + + + Delete build '{0}'? + + GuiManuscriptBuild - + Build Manuscript - + Output Format - + Table of Contents - + Path - + File Name - + Reset file name to default @@ -2444,22 +2515,22 @@ - + &Build - + Select Folder - + Output folder does not exist. - + The file already exists. Do you want to overwrite it? @@ -2467,18 +2538,18 @@ GuiNovelDetails - - + + Novel Details - + Overview - + Contents @@ -2486,58 +2557,58 @@ GuiNovelToolBar - + Outline of {0} - + Novel Root - + Refresh - + Last Column - + Hidden - + Point of View Character - + Focus Character - + Novel Plot - - + + Column Size - + More Options - + Maximum column size in % @@ -2545,7 +2616,7 @@ GuiNovelTree - + No meta data @@ -2553,49 +2624,49 @@ GuiOutlineDetails - - - + + + Title - + Chapter - + Scene - + Section - + Document - + Status - + Synopsis - + Title Details - + Reference Tags @@ -2603,7 +2674,7 @@ GuiOutlineHeaderMenu - + Select Columns @@ -2611,17 +2682,17 @@ GuiOutlineToolBar - + Outline of - + Refresh - + Export CSV @@ -2629,7 +2700,7 @@ GuiOutlineTree - + Save Outline As @@ -2637,13 +2708,13 @@ GuiPreferences - - + + Preferences - + Search @@ -2663,7 +2734,7 @@ - + Requires restart to take effect. @@ -2675,571 +2746,627 @@ - General colour theme and icons. + User interface colour theme. + + + + + Icon theme + User interface icon theme. + + + + Application font - + Hide vertical scroll bars in main windows - - + + Scrolling available with mouse wheel and keys only. - + Hide horizontal scroll bars in main windows - + Use the system's font selection dialog - + Turn off to use the Qt font dialog, which may have more options. - - - Document Style - - - Document colour theme + Prefer character count over word count + Display character count instead where available. + + + + + Document Style + + + + + Document colour theme + + + + Colour theme for the editor and viewer. - + Document font - - - + + + Applies to both document editor and viewer. - - Emphasise partition and chapter labels - - - - - Makes them stand out in the project tree. - - - - + Show full path in document header - + Add the parent folder names to the header. - + Include project notes in status bar word count - + + Project View + + + + + Project tree icon colours + + + + + Override colours for project icons. + + + + + Keep theme colours on documents + + + + + Only override icon colours for folders. + + + + + Emphasise partition and chapter labels + + + + + Makes them stand out in the project tree. + + + + Behaviour - + Save document interval - + How often the document is automatically saved. - - + + seconds - + Save project interval - + How often the project is automatically saved. - + Ask before exiting novelWriter - + Only applies when a project is open. - + Project Backup - + Browse - + Backup storage location - - + + Path: {0} - + Run backup when the project is closed - + Can be overridden for individual projects in Project Settings. - + Ask before running backup - + If off, backups will run in the background. - + Session Timer - + Pause the session timer when not writing - + Also pauses when the application window does not have focus. - + Editor inactive time before pausing timer - + User activity includes typing and changing the content. - + minutes - + Writing - + Text Flow - + Maximum text width in "Normal Mode" - + Set to 0 to disable this feature. - - - - + + + + + px - + Maximum text width in "Focus Mode" - + The maximum width cannot be disabled. - + Hide document footer in "Focus Mode" - + Hide the information bar in the document editor. - + Justify the text margins - + Minimum text margin - + Tab width - + The width of a tab key press in the editor and viewer. - + Text Editing - + Spell check language - + Available languages are determined by your system. - + Auto-select word under cursor - + Apply formatting to word under cursor if no selection is made. - - Show tabs and spaces + + Cursor width - - Show line endings - - - - - Editor Scrolling - - - - - Scroll past end of the document - - - - - Also centres the cursor when scrolling. - - - - - Typewriter style scrolling when you type - - - - - Keeps the cursor at a fixed vertical position. - - - - - Minimum position for Typewriter scrolling - - - - - Percentage of the editor height from the top. - - - - - Text Highlighting - - - - - None - - - - - Single Quotes - - - - - Double Quotes - - - - - Both + + The width of the text cursor of the editor. - Highlight dialogue + Show tabs and spaces - - Applies to the selected quote styles. + + Show line endings - - Alternative dialogue symbols + + Editor Scrolling - - Custom highlighting of dialogue text. + + Scroll past the end of the document - - Allow open-ended dialogue + + Also centres the cursor when scrolling. - - Highlight dialogue line with no closing quote. + + Typewriter style scrolling when you type - - Dialogue line symbols + + Keeps the cursor at a fixed vertical position. - - Lines starting with any of these symbols are dialogue. + + Minimum position for Typewriter scrolling - - Narrator break symbol + + Percentage of the editor height from the top. - - Symbol to indicate a narrator break in dialogue. + + Text Highlighting - Alternating dialogue/narration symbol + None - Alternates dialogue highlighting within any paragraph. + Single Quotes + + + + + Double Quotes + + + + + Both + + + + + Highlight dialogue - Add highlight colour to emphasised text + Applies to the selected quote styles. - - - Applies to the document editor only. + + Alternative dialogue symbols - - Highlight multiple or trailing spaces + + Custom highlighting of dialogue text. - - Text Automation + + Allow open-ended dialogue - - Auto-replace text as you type + + Highlight dialogue line with no closing quote. - - Allow the editor to replace symbols as you type. + + Dialogue line symbols - - Auto-replace single quotes + + Lines starting with any of these symbols are dialogue. - - - Try to guess which is an opening or a closing quote. + + Narrator break symbol - - Auto-replace double quotes + + Symbol to indicate a narrator break in dialogue. + + + + + Alternating dialogue/narration symbol - Auto-replace dashes + Alternates dialogue highlighting within any paragraph. - - Double and triple hyphens become short and long dashes. + + Add highlight colour to emphasised text - - Auto-replace dots + + + Applies to the document editor only. - - Three consecutive dots become ellipsis. - - - - - Insert non-breaking space before + + Highlight multiple or trailing spaces - Automatically add space before any of these symbols. - - - - - Insert non-breaking space after + Text Automation - Automatically add space after any of these symbols. + Auto-replace text as you type - - Use thin space instead + + Allow the editor to replace symbols as you type. + Auto-replace single quotes + + + + + + Try to guess which is an opening or a closing quote. + + + + + Auto-replace double quotes + + + + + Auto-replace dashes + + + + + Double and triple hyphens become short and long dashes. + + + + + Auto-replace dots + + + + + Three consecutive dots become ellipsis. + + + + + Insert non-breaking space before + + + + + Automatically add space before any of these symbols. + + + + + Insert non-breaking space after + + + + + Automatically add space after any of these symbols. + + + + + Use thin space instead + + + + Inserts a thin space instead of a regular space. - + Quotation Style - + Single quote open style - + The symbol to use for a leading single quote. - + Single quote close style - + The symbol to use for a trailing single quote. - + Double quote open style - + The symbol to use for a leading double quote. - + Double quote close style - + The symbol to use for a trailing double quote. - + Backup Directory @@ -3247,27 +3374,27 @@ GuiProjectSearch - + Project Search - + Case Sensitive - + Whole Words Only - + RegEx Mode - + Search for @@ -3281,22 +3408,22 @@ - + Settings - + Status - + Importance - + Auto-Replace @@ -3309,42 +3436,42 @@ - + Quick Links - + Move Up - + Move Down - + Add Item - + Expand All - + Collapse All - + Empty Trash - + More Options @@ -3352,93 +3479,98 @@ GuiProjectTree - + Did not find anywhere to add the file or folder! - + Cannot add new files or folders to the Trash folder. - + New Note - - New Chapter - - - - - New Scene + + New Part + New Chapter + + + + + New Scene + + + + New Document - + New Folder - + No documents selected for merging. - + Merged - - + + Could not write document content. - + Do you want to duplicate this document? - + Do you want to duplicate this item and all child items? - + Could not duplicate all items. - + Root folders can only be deleted when they are empty. - + Permanently delete selected item(s)? - + Move selected item(s) to Trash? - + The Trash folder is already empty. - + Permanently delete {0} file(s) from Trash? @@ -3446,7 +3578,7 @@ GuiQuoteSelect - + Select Quote Style @@ -3454,42 +3586,42 @@ GuiSideBar - + Project Tree View - + Novel Tree View - + Project Search - + Novel Outline View - + Build Manuscript - + Novel Details - + Writing Statistics - + Settings @@ -3497,37 +3629,37 @@ GuiWelcome - + Welcome - + List - + New - + Browse - + Cancel - + Create - + Open @@ -3536,7 +3668,7 @@ GuiWordList - + Project Word List @@ -3579,147 +3711,147 @@ GuiWritingStats - + Writing Statistics - + Session Start - + Length - + Idle - + Words - + Histogram - + Sum Totals - + Total Time: - + Idle Time: - + Filtered Time: - + Novel Word Count: - + Notes Word Count: - + Total Word Count: - + Filters - + Count novel files - + Count note files - + Hide zero word count - + Hide negative word count - + Group entries by day - + Show idle time - + Word count cap for the histogram - - - Save As - - JSON Data File (.json) - + CSV Data File (.csv) - - JSON Data File - - - - - CSV Data File + + Save As + JSON Data File + + + + + CSV Data File + + + + Save Data As - + {0} file successfully written to: - + Failed to write {0} file. @@ -3727,156 +3859,156 @@ NWProject - + Could not delete document file. - + Not a known project file format. - - - - + + + + Path: {0} - + Project file not found. - + Failed to open project. - + Unknown - + Project file does not appear to be a novelWriterXML file. - + Unknown or unsupported novelWriter project file format. The project cannot be opened by this version of novelWriter. The file was saved with novelWriter version {0}. - + Failed to parse project xml. - + The file format of your project is about to be updated. If you proceed, older versions of novelWriter will no longer be able to open this project. Continue? - + This project was saved by a newer version of novelWriter, version {0}. This is version {1}. If you continue to open the project, some attributes and settings may not be preserved, but the overall project should be fine. Continue opening the project? - + Recovered - + Found {0} orphaned file(s) in the project. {1} file(s) were recovered. - + Opened Project: {0} - + There is no project open. - + Failed to save project. - + Saved Project: {0} - + Backing up project ... - + Cannot backup project because no project name is set. Please set a Project Name in Project Settings. - + Could not create backup folder. - + Created a backup of your project of size {0}B. - + Could not write backup archive. - + Project backed up to '{0}' - - + + New - + Note - + Draft - + Finished - + Minor - + Major - + Main @@ -3884,7 +4016,7 @@ NovelSelector - + All Novel Folders @@ -3892,99 +4024,104 @@ ProjectBuilder - + The target folder is not empty. Please choose another folder. - + An error occurred while trying to create the project. - + New Project - + + Author Name + + + + Title Page - - Address + + Address Line - + By - + Word Count - + Summary of the chapter. - + Summary of the scene. - + A short description. - + Chapter {0} - - + + Scene {0} - + Main Plot - + Protagonist - + Main Location - - + + The target folder already exists. Please choose another folder. - + Could not copy project files. - + Failed to create a new example project. - + Failed to create a new example project. Could not find the necessary files. They seem to be missing from this installation. @@ -4121,22 +4258,22 @@ SharedData - + novelWriter Project File or Zip File - + novelWriter Project File - + Open Project - + Select Font @@ -4144,60 +4281,70 @@ Stats - + Characters - + Characters in Text - + Characters in Headings - + Paragraphs - + Headings - + Characters, No Spaces - + Characters in Text, No Spaces - + Characters in Headings, No Spaces - + Words - + Words in Text - + Words in Headings + + + Characters: {0} ({1}) + + + + + Words: {0} ({1}) + + VersionInfoWidget @@ -4245,32 +4392,32 @@ _ContentsPage - + Table of Contents - + Title - + Words - + Pages - + Page - + Progress @@ -4285,17 +4432,17 @@ - + Chapters on odd pages - + Untitled - + END @@ -4303,32 +4450,32 @@ _DetailsWidget - + Setting - + Value - + Name - + Selection - + Title - + Hidden @@ -4336,37 +4483,37 @@ _FilterTab - + Included in manuscript - + Excluded from manuscript - + Always included - + Always excluded - + Reset to default - + Mark selection as - + Select Root Folders @@ -4374,22 +4521,22 @@ _GuiAlert - + Information - + Warning - + Error - + Question @@ -4397,84 +4544,84 @@ _HeadingsTab - + Hide - - + + Editing: {0} - - + + None - + Title - + Chapter Number - + Chapter Number (Word) - + Chapter Number (Upper Case Roman) - + Chapter Number (Lower Case Roman) - + Scene Number (In Chapter) - + Scene Number (Absolute) - + Point of View Character - + Focus Character - + Insert - + Apply - + Centre - + Page Break @@ -4482,117 +4629,117 @@ _NewProjectForm - + Required - + Optional - + Create a fresh project - + Create an example project - + Copy an existing project - + Project Name - + Author - + Project Path - + Prefill Project - + Set to 0 to only add scenes - + Add {0} chapter documents - + Add {0} scene documents (to each chapter) - + Add a folder for plot notes - + Add a folder for character notes - + Add a folder for location notes - + Add example notes to the above - + Chapters and Scenes - + Project Notes - + Create New Project - + Select Project Folder - + Fresh Project - + Example Project - + Template: {0} @@ -4600,7 +4747,7 @@ _NewProjectPage - + A project name is required. @@ -4608,27 +4755,27 @@ _OpenProjectPage - + The project path is not reachable. - + Path - + Remove '{0}' from the recent projects list? The project files will not be deleted. - + Open Project - + Remove Project @@ -4636,54 +4783,54 @@ _OverviewPage - + Project - - + + Name - + Revisions - + Editing Time - - + + Word Count - + In Novels - + In Notes - + Selected Novel - + Chapters - + Scenes @@ -4691,27 +4838,27 @@ _PreviewWidget - + Press the "Preview" button to generate ... - + Processing ... - + Done - + Built - + No Preview @@ -4719,12 +4866,12 @@ _ProjectListModel - + Word Count - + Last Opened @@ -4732,22 +4879,22 @@ _ReplacePage - + Text Auto-Replace for Preview and Build - + Keyword - + Replace With - + Select item to edit @@ -4755,49 +4902,49 @@ _SettingsPage - + Project name - + Changing this will affect the backup path. - + Author(s) - - + + Only used when building the manuscript. - + Project language - + Default - + Spell check language - - + + Overrides main preferences. - + Disable backup on close @@ -4805,132 +4952,132 @@ _StatusPage - + Status - + Novel Document Status Levels - + Importance - + Project Note Importance Levels - + Not in use - + Used once - + Used by {0} items - + Select Colour - + Label - + Usage - + Add Label - + Delete Label - + Move Up - + Move Down - + Import Labels - + Export Labels - + Select item to edit - + Colour - + Circles ... - + Bars ... - + Blocks ... - + Shape - + New Item - + Cannot delete a status item that is in use. - + Import File - + Export File @@ -4968,91 +5115,91 @@ - + Rename to Heading - + Set Active to ... - + Toggle Active - + Set Status to ... - - + + Manage Labels ... - + Set Importance to ... - + Transform ... - - - - + + + + Convert to {0} - + Merge Child Items into Self - + Merge Child Items into New - + Merge Documents in Folder - + Split Document by Headings - + Expand All - + Collapse All - + Delete Permanently - + Move to Trash - + Do you want to convert the folder to a {0}? This action cannot be reversed. @@ -5060,7 +5207,7 @@ _UpdatableMenu - + From Template @@ -5068,12 +5215,12 @@ _ViewPanelBackRefs - + Document - + First Heading @@ -5081,27 +5228,27 @@ _ViewPanelKeyWords - + Tag - + Importance - + Document - + Heading - + Short Description From 6b4aac023578955d9d53244d13373bf3eac705dc Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Tue, 20 May 2025 21:16:34 +0200 Subject: [PATCH 27/29] Use a copy of the build settings for the build settings dialog (#2350) --- novelwriter/core/buildsettings.py | 2 +- novelwriter/tools/manussettings.py | 20 +++++++++++++------- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/novelwriter/core/buildsettings.py b/novelwriter/core/buildsettings.py index 6129de05..c44d9223 100644 --- a/novelwriter/core/buildsettings.py +++ b/novelwriter/core/buildsettings.py @@ -390,7 +390,7 @@ class BuildSettings: 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._changed |= (value != self._settings[key]) self._settings[key] = value return diff --git a/novelwriter/tools/manussettings.py b/novelwriter/tools/manussettings.py index 521e08a0..967a03ff 100644 --- a/novelwriter/tools/manussettings.py +++ b/novelwriter/tools/manussettings.py @@ -80,7 +80,8 @@ class GuiBuildSettings(NToolDialog): logger.debug("Create: GuiBuildSettings") self.setObjectName("GuiBuildSettings") - self._build = build + # Make a copy of the build object + self._build = BuildSettings.fromDict(build.pack()) self.setWindowTitle(self.tr("Manuscript Build Settings")) self.setMinimumSize(700, 400) @@ -184,6 +185,7 @@ class GuiBuildSettings(NToolDialog): settings. """ logger.debug("Closing: GuiBuildSettings") + self._applyChanges() self._askToSaveBuild() self._saveSettings() event.accept() @@ -209,6 +211,7 @@ class GuiBuildSettings(NToolDialog): @pyqtSlot("QAbstractButton*") def _dialogButtonClicked(self, button: QAbstractButton) -> None: """Handle button clicks from the dialog button box.""" + self._applyChanges() role = self.buttonBox.buttonRole(button) if role == QtRoleApply: self._emitBuildData() @@ -216,6 +219,7 @@ class GuiBuildSettings(NToolDialog): self._emitBuildData() self.close() elif role == QtRoleReject: + self._build.resetChangedState() self.close() return @@ -228,10 +232,9 @@ class GuiBuildSettings(NToolDialog): whether the user wants to save them. """ if self._build.changed: - response = SHARED.question(self.tr( + if SHARED.question(self.tr( "Do you want to save your changes to '{0}'?" - ).format(self._build.name)) - if response: + ).format(self._build.name)): self._emitBuildData() self._build.resetChangedState() return @@ -246,14 +249,17 @@ class GuiBuildSettings(NToolDialog): pOptions.setValue("GuiBuildSettings", "treeWidth", treeWidth) pOptions.setValue("GuiBuildSettings", "filterWidth", filterWidth) pOptions.saveSettings() + return + def _applyChanges(self) -> None: + """Apply all settings changes to the build object.""" + self._build.setName(self.editBuildName.text()) + self.optTabHeadings.saveContent() + self.optTabFormatting.saveContent() return def _emitBuildData(self) -> None: """Assemble the build data and emit the signal.""" - self._build.setName(self.editBuildName.text()) - self.optTabHeadings.saveContent() - self.optTabFormatting.saveContent() self.newSettingsReady.emit(self._build) self._build.resetChangedState() return From f177f82417dcae3ceda7f867b6b646aeb85adcb4 Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Tue, 20 May 2025 21:32:58 +0200 Subject: [PATCH 28/29] Update tests --- tests/test_tools/test_tools_manussettings.py | 111 +++++++++++-------- 1 file changed, 64 insertions(+), 47 deletions(-) diff --git a/tests/test_tools/test_tools_manussettings.py b/tests/test_tools/test_tools_manussettings.py index b2d0479f..91a9f4f9 100644 --- a/tests/test_tools/test_tools_manussettings.py +++ b/tests/test_tools/test_tools_manussettings.py @@ -72,7 +72,7 @@ def testToolBuildSettings_Init(qtbot, nwGUI, projPath, mockRnd): @pyqtSlot(BuildSettings) def _testNewSettingsReady(new: BuildSettings): nonlocal triggered - assert new is build + assert new.buildID == build.buildID triggered = True # Capture Apply button @@ -103,6 +103,7 @@ def testToolBuildSettings_Init(qtbot, nwGUI, projPath, mockRnd): with qtbot.waitSignal(bSettings.newSettingsReady, timeout=5000): bSettings.newSettingsReady.connect(_testNewSettingsReady) + bSettings._build._changed = True bSettings.close() assert triggered @@ -140,6 +141,9 @@ def testToolBuildSettings_Filter(qtbot, nwGUI, projPath, mockRnd): bSettings.show() bSettings.loadContent() + sBuild = bSettings._build + assert sBuild.buildID == build.buildID + filterTab = bSettings.optTabSelect button = bSettings.sidebar._group.button(bSettings.OPT_FILTERS) assert button is not None @@ -153,15 +157,15 @@ def testToolBuildSettings_Filter(qtbot, nwGUI, projPath, mockRnd): # Un-toggle note folders filterTab.filterOpt._widgets[switchMap["worldRoot"]].setChecked(False) # World Root assert filterTab.optTree.topLevelItemCount() == 3 - assert C.hWorldRoot in build._skipRoot + assert C.hWorldRoot in sBuild._skipRoot filterTab.filterOpt._widgets[switchMap["charRoot"]].setChecked(False) # Char Root assert filterTab.optTree.topLevelItemCount() == 2 - assert C.hCharRoot in build._skipRoot + assert C.hCharRoot in sBuild._skipRoot filterTab.filterOpt._widgets[switchMap["plotRoot"]].setChecked(False) # Plot Root assert filterTab.optTree.topLevelItemCount() == 1 - assert C.hPlotRoot in build._skipRoot + assert C.hPlotRoot in sBuild._skipRoot # Reset Plot and Char filterTab.filterOpt._widgets[switchMap["plotRoot"]].setChecked(True) @@ -170,7 +174,7 @@ def testToolBuildSettings_Filter(qtbot, nwGUI, projPath, mockRnd): # Switch off novel docs filterTab.filterOpt._widgets[switchMap["incNovel"]].setChecked(False) - assert build.buildItemFilter(SHARED.project) == { + assert sBuild.buildItemFilter(SHARED.project) == { C.hNovelRoot: (False, FilterMode.SKIPPED), C.hTitlePage: (False, FilterMode.FILTERED), C.hChapterDir: (False, FilterMode.SKIPPED), @@ -186,7 +190,7 @@ def testToolBuildSettings_Filter(qtbot, nwGUI, projPath, mockRnd): # Switch on note docs filterTab.filterOpt._widgets[switchMap["incNotes"]].setChecked(True) - assert build.buildItemFilter(SHARED.project) == { + assert sBuild.buildItemFilter(SHARED.project) == { C.hNovelRoot: (False, FilterMode.SKIPPED), C.hTitlePage: (False, FilterMode.FILTERED), C.hChapterDir: (False, FilterMode.SKIPPED), @@ -202,7 +206,7 @@ def testToolBuildSettings_Filter(qtbot, nwGUI, projPath, mockRnd): # Switch on inactive docs filterTab.filterOpt._widgets[switchMap["incInactive"]].setChecked(True) - assert build.buildItemFilter(SHARED.project) == { + assert sBuild.buildItemFilter(SHARED.project) == { C.hNovelRoot: (False, FilterMode.SKIPPED), C.hTitlePage: (False, FilterMode.FILTERED), C.hChapterDir: (False, FilterMode.SKIPPED), @@ -220,7 +224,7 @@ def testToolBuildSettings_Filter(qtbot, nwGUI, projPath, mockRnd): filterTab._treeMap[C.hChapterDoc].setSelected(True) filterTab._treeMap[C.hSceneDoc].setSelected(True) filterTab.includedButton.click() - assert build.buildItemFilter(SHARED.project) == { + assert sBuild.buildItemFilter(SHARED.project) == { C.hNovelRoot: (False, FilterMode.SKIPPED), C.hTitlePage: (False, FilterMode.FILTERED), C.hChapterDir: (False, FilterMode.SKIPPED), @@ -239,7 +243,7 @@ def testToolBuildSettings_Filter(qtbot, nwGUI, projPath, mockRnd): filterTab._treeMap[hPlotDoc].setSelected(True) # type: ignore filterTab._treeMap[hCharDoc].setSelected(True) # type: ignore filterTab.excludedButton.click() - assert build.buildItemFilter(SHARED.project) == { + assert sBuild.buildItemFilter(SHARED.project) == { C.hNovelRoot: (False, FilterMode.SKIPPED), C.hTitlePage: (False, FilterMode.FILTERED), C.hChapterDir: (False, FilterMode.SKIPPED), @@ -255,7 +259,7 @@ def testToolBuildSettings_Filter(qtbot, nwGUI, projPath, mockRnd): # Switch on novel docs filterTab.filterOpt._widgets[switchMap["incNovel"]].setChecked(True) - assert build.buildItemFilter(SHARED.project) == { + assert sBuild.buildItemFilter(SHARED.project) == { C.hNovelRoot: (False, FilterMode.SKIPPED), C.hTitlePage: (True, FilterMode.FILTERED), # Now enabled C.hChapterDir: (False, FilterMode.SKIPPED), @@ -273,7 +277,7 @@ def testToolBuildSettings_Filter(qtbot, nwGUI, projPath, mockRnd): filterTab.optTree.clearSelection() filterTab._treeMap[C.hNovelRoot].setSelected(True) filterTab.resetButton.click() - assert build.buildItemFilter(SHARED.project) == { + assert sBuild.buildItemFilter(SHARED.project) == { C.hNovelRoot: (False, FilterMode.SKIPPED), C.hTitlePage: (True, FilterMode.FILTERED), C.hChapterDir: (False, FilterMode.SKIPPED), @@ -294,7 +298,7 @@ def testToolBuildSettings_Filter(qtbot, nwGUI, projPath, mockRnd): filterTab._treeMap[hPlotDoc].setSelected(True) # type: ignore filterTab._treeMap[hCharDoc].setSelected(True) # type: ignore filterTab.resetButton.click() - assert build.buildItemFilter(SHARED.project) == { + assert sBuild.buildItemFilter(SHARED.project) == { C.hNovelRoot: (False, FilterMode.SKIPPED), C.hTitlePage: (True, FilterMode.FILTERED), C.hChapterDir: (False, FilterMode.SKIPPED), @@ -429,6 +433,8 @@ def testToolBuildSettings_Headings(qtbot, nwGUI): # Edit a Heading # ============== + sBuild = bSettings._build + assert sBuild.buildID == build.buildID # Create new format of all bits headTab.btnChapter.click() @@ -447,13 +453,13 @@ def testToolBuildSettings_Headings(qtbot, nwGUI): headTab.aInsScAbs.trigger() assert headTab.editTextBox.toPlainText() == allFmt headTab.btnApply.click() - assert build.getStr("headings.fmtChapter") == allFmt + assert sBuild.getStr("headings.fmtChapter") == allFmt # Check complex format headTab.btnChapter.click() headTab.editTextBox.setPlainText(f"Chapter {nwHeadFmt.CH_NUM}\n{nwHeadFmt.TITLE}\n") headTab.btnApply.click() - assert build.getStr("headings.fmtChapter") == ( + assert sBuild.getStr("headings.fmtChapter") == ( f"Chapter {nwHeadFmt.CH_NUM}{nwHeadFmt.BR}{nwHeadFmt.TITLE}" ) @@ -461,39 +467,42 @@ def testToolBuildSettings_Headings(qtbot, nwGUI): headTab.btnPart.click() headTab.editTextBox.setPlainText(nwHeadFmt.TITLE) headTab.btnApply.click() - assert build.getStr("headings.fmtPart") == nwHeadFmt.TITLE + assert sBuild.getStr("headings.fmtPart") == nwHeadFmt.TITLE headTab.btnChapter.click() headTab.editTextBox.setPlainText(nwHeadFmt.TITLE) headTab.btnApply.click() - assert build.getStr("headings.fmtChapter") == nwHeadFmt.TITLE + assert sBuild.getStr("headings.fmtChapter") == nwHeadFmt.TITLE headTab.btnUnnumbered.click() headTab.editTextBox.setPlainText(nwHeadFmt.TITLE) headTab.btnApply.click() - assert build.getStr("headings.fmtUnnumbered") == nwHeadFmt.TITLE + assert sBuild.getStr("headings.fmtUnnumbered") == nwHeadFmt.TITLE headTab.btnScene.click() headTab.editTextBox.setPlainText(nwHeadFmt.TITLE) headTab.btnApply.click() - assert build.getStr("headings.fmtScene") == nwHeadFmt.TITLE + assert sBuild.getStr("headings.fmtScene") == nwHeadFmt.TITLE headTab.btnAScene.click() headTab.editTextBox.setPlainText(nwHeadFmt.TITLE) headTab.btnApply.click() - assert build.getStr("headings.fmtAltScene") == nwHeadFmt.TITLE + assert sBuild.getStr("headings.fmtAltScene") == nwHeadFmt.TITLE headTab.btnSection.click() headTab.editTextBox.setPlainText(nwHeadFmt.TITLE) headTab.btnApply.click() - assert build.getStr("headings.fmtSection") == nwHeadFmt.TITLE + assert sBuild.getStr("headings.fmtSection") == nwHeadFmt.TITLE # Check hide switches headTab.swtScene.setChecked(True) headTab.swtSection.setChecked(True) headTab.saveContent() - assert build.getBool("headings.hideScene") is True - assert build.getBool("headings.hideSection") is True + sBuild = bSettings._build + assert sBuild.buildID == build.buildID + + assert sBuild.getBool("headings.hideScene") is True + assert sBuild.getBool("headings.hideSection") is True # Finish button = bSettings.buttonBox.button(QtDialogClose) @@ -556,16 +565,18 @@ def testToolBuildSettings_FormatTextContent(qtbot, nwGUI): # Save values fmtTab.saveContent() + sBuild = bSettings._build + assert sBuild.buildID == build.buildID - assert build.getBool("text.includeBodyText") is True - assert build.getBool("text.includeSynopsis") is True - assert build.getBool("text.includeComments") is True - assert build.getBool("text.includeStory") is True - assert build.getBool("text.includeNotes") is True - assert build.getBool("text.includeKeywords") is True - assert build.getStr("text.ignoredKeywords") in ("@custom, @object", "@object, @custom") + assert sBuild.getBool("text.includeBodyText") is True + assert sBuild.getBool("text.includeSynopsis") is True + assert sBuild.getBool("text.includeComments") is True + assert sBuild.getBool("text.includeStory") is True + assert sBuild.getBool("text.includeNotes") is True + assert sBuild.getBool("text.includeKeywords") is True + assert sBuild.getStr("text.ignoredKeywords") in ("@custom, @object", "@object, @custom") - assert build.getBool("text.addNoteHeadings") is True + assert sBuild.getBool("text.addNoteHeadings") is True # Finish button = bSettings.buttonBox.button(QtDialogClose) @@ -623,15 +634,17 @@ def testToolBuildSettings_FormatTextFormat(monkeypatch, qtbot, nwGUI): # Save values fmtTab.saveContent() + sBuild = bSettings._build + assert sBuild.buildID == build.buildID - assert build.getStr("format.textFont") == testFont.toString() - assert build.getFloat("format.lineHeight") == 1.15 + assert sBuild.getStr("format.textFont") == testFont.toString() + assert sBuild.getFloat("format.lineHeight") == 1.15 - assert build.getBool("format.justifyText") is True - assert build.getBool("format.stripUnicode") is True - assert build.getBool("format.replaceTabs") is True - assert build.getBool("format.keepBreaks") is False - assert build.getBool("format.showDialogue") is True + assert sBuild.getBool("format.justifyText") is True + assert sBuild.getBool("format.stripUnicode") is True + assert sBuild.getBool("format.replaceTabs") is True + assert sBuild.getBool("format.keepBreaks") is False + assert sBuild.getBool("format.showDialogue") is True # Check that the font dialog doesn't fail with monkeypatch.context() as mp: @@ -682,10 +695,12 @@ def testToolBuildSettings_FormatFirstLineIndent(monkeypatch, qtbot, nwGUI): # Save values fmtTab.saveContent() + sBuild = bSettings._build + assert sBuild.buildID == build.buildID - assert build.getBool("format.firstLineIndent") is True - assert build.getFloat("format.firstIndentWidth") == 2.0 - assert build.getBool("format.indentFirstPar") is True + assert sBuild.getBool("format.firstLineIndent") is True + assert sBuild.getFloat("format.firstIndentWidth") == 2.0 + assert sBuild.getBool("format.indentFirstPar") is True # Finish button = bSettings.buttonBox.button(QtDialogClose) @@ -800,15 +815,17 @@ def testToolBuildSettings_FormatOutput(qtbot, nwGUI): # Save values fmtTab.saveContent() + sBuild = bSettings._build + assert sBuild.buildID == build.buildID - assert build.getStr("doc.pageHeader") == "Stuff" - assert build.getInt("doc.pageCountOffset") == 1 - assert build.getBool("doc.colorHeadings") is False - assert build.getBool("doc.scaleHeadings") is False - assert build.getBool("doc.boldHeadings") is False + assert sBuild.getStr("doc.pageHeader") == "Stuff" + assert sBuild.getInt("doc.pageCountOffset") == 1 + assert sBuild.getBool("doc.colorHeadings") is False + assert sBuild.getBool("doc.scaleHeadings") is False + assert sBuild.getBool("doc.boldHeadings") is False - assert build.getBool("html.addStyles") is True - assert build.getBool("html.preserveTabs") is True + assert sBuild.getBool("html.addStyles") is True + assert sBuild.getBool("html.preserveTabs") is True # Reset header format fmtTab.btnPageHeader.click() From 7fa7fdee3f5f2bdc7a997a0e7aafe0c2e04bbf5d Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Tue, 20 May 2025 21:39:16 +0200 Subject: [PATCH 29/29] Make a small optimisation to build settings dialog --- novelwriter/tools/manussettings.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/novelwriter/tools/manussettings.py b/novelwriter/tools/manussettings.py index 967a03ff..e5ddc66b 100644 --- a/novelwriter/tools/manussettings.py +++ b/novelwriter/tools/manussettings.py @@ -211,11 +211,12 @@ class GuiBuildSettings(NToolDialog): @pyqtSlot("QAbstractButton*") def _dialogButtonClicked(self, button: QAbstractButton) -> None: """Handle button clicks from the dialog button box.""" - self._applyChanges() role = self.buttonBox.buttonRole(button) if role == QtRoleApply: + self._applyChanges() self._emitBuildData() elif role == QtRoleAccept: + self._applyChanges() self._emitBuildData() self.close() elif role == QtRoleReject: