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 1/8] 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 2/8] 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 3/8] 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 4/8] 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 5/8] 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 6/8] 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 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 7/8] 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 8/8] 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()