"
##
@@ -1078,63 +1061,61 @@ class IndexHeading:
##
@property
- def key(self):
+ def key(self) -> str:
return self._key
@property
- def line(self):
+ def line(self) -> int:
return self._line
@property
- def level(self):
+ def level(self) -> str:
return self._level
@property
- def title(self):
+ def title(self) -> str:
return self._title
@property
- def charCount(self):
+ def charCount(self) -> int:
return self._charCount
@property
- def wordCount(self):
+ def wordCount(self) -> int:
return self._wordCount
@property
- def paraCount(self):
+ def paraCount(self) -> int:
return self._paraCount
@property
- def synopsis(self):
+ def synopsis(self) -> str:
return self._synopsis
@property
- def tag(self):
+ def tag(self) -> str:
return self._tag
@property
- def references(self):
+ def references(self) -> dict:
return self._refs
##
# Setters
##
- def setLevel(self, level):
- """Set the level of the header if it's a valid value.
- """
+ def setLevel(self, level: str):
+ """Set the level of the header if it's a valid value."""
if level in nwHeaders.H_VALID:
self._level = level
return
- def setLine(self, line):
- """Set the line number of a heading.
- """
+ def setLine(self, line: int):
+ """Set the line number of a heading."""
self._line = max(0, checkInt(line, 0))
return
- def setCounts(self, charCount, wordCount, paraCount):
+ def setCounts(self, charCount: int, wordCount: int, paraCount: int):
"""Set the character, word and paragraph count. Make sure the
value is an integer and is not smaller than 0.
"""
@@ -1143,19 +1124,17 @@ class IndexHeading:
self._paraCount = max(0, checkInt(paraCount, 0))
return
- def setSynopsis(self, synopText):
- """Set the synopsis text and make sure it is a string.
- """
- self._synopsis = str(synopText)
+ def setSynopsis(self, text: str):
+ """Set the synopsis text and make sure it is a string."""
+ self._synopsis = str(text)
return
- def setTag(self, tagKey):
- """Set the tag for references, and make sure it is a string.
- """
+ def setTag(self, tagKey: str):
+ """Set the tag for references, and make sure it is a string."""
self._tag = str(tagKey)
return
- def addReference(self, tagKey, refType):
+ def addReference(self, tagKey: str, refType: str):
"""Add a record of a reference tag, and what keyword types it is
associated with.
"""
@@ -1169,9 +1148,8 @@ class IndexHeading:
# Data Methods
##
- def packData(self):
- """Pack the values into a dictionary for saving to cache.
- """
+ def packData(self) -> dict:
+ """Pack the values into a dictionary for saving to cache."""
return {
"level": self._level,
"title": self._title,
@@ -1183,7 +1161,7 @@ class IndexHeading:
"synopsis": self._synopsis,
}
- def packReferences(self):
+ def packReferences(self) -> dict[str, str]:
"""Pack references into a dictionary for saving to cache.
Multiple types are packed into a sorted, comma separated string.
It is sorted to prevent creating unnecessary diffs as the order
@@ -1191,9 +1169,8 @@ class IndexHeading:
"""
return {key: ",".join(sorted(list(value))) for key, value in self._refs.items()}
- def unpackData(self, data):
- """Unpack a heading entry from a dictionary.
- """
+ def unpackData(self, data: dict):
+ """Unpack a heading entry from a dictionary."""
self.setLevel(data.get("level", "H0"))
self._title = str(data.get("title", ""))
self._tag = str(data.get("tag", ""))
@@ -1206,9 +1183,8 @@ class IndexHeading:
self._synopsis = str(data.get("synopsis", ""))
return
- def unpackReferences(self, data):
- """Unpack a set of references from a dictionary.
- """
+ def unpackReferences(self, data: dict):
+ """Unpack a set of references from a dictionary."""
for tagKey, refTypes in data.items():
if not isinstance(tagKey, str):
raise ValueError("itemIndex reference key must be a string")
@@ -1228,7 +1204,7 @@ class IndexHeading:
# Simple Word Counter
# =============================================================================================== #
-def countWords(theText):
+def countWords(text: str) -> tuple[int, int, int]:
"""Count words in a piece of text, skipping special syntax and
comments.
"""
@@ -1237,25 +1213,26 @@ def countWords(theText):
paraCount = 0
prevEmpty = True
- if not isinstance(theText, str):
+ if not isinstance(text, str):
return charCount, wordCount, paraCount
# We need to treat dashes as word separators for counting words.
# The check+replace approach is much faster than direct replace for
# large texts, and a bit slower for small texts, but in the latter
# case it doesn't really matter.
- if nwUnicode.U_ENDASH in theText:
- theText = theText.replace(nwUnicode.U_ENDASH, " ")
- if nwUnicode.U_EMDASH in theText:
- theText = theText.replace(nwUnicode.U_EMDASH, " ")
+ if nwUnicode.U_ENDASH in text:
+ text = text.replace(nwUnicode.U_ENDASH, " ")
+ if nwUnicode.U_EMDASH in text:
+ text = text.replace(nwUnicode.U_EMDASH, " ")
- for aLine in theText.splitlines():
+ for aLine in text.splitlines():
countPara = True
if not aLine:
prevEmpty = True
continue
+
if aLine[0] == "@" or aLine[0] == "%":
continue
diff --git a/tests/reference/coreIndex_LoadSave_tagsIndex.json b/tests/reference/coreIndex_LoadSave_tagsIndex.json
index 738e7916..63be8658 100644
--- a/tests/reference/coreIndex_LoadSave_tagsIndex.json
+++ b/tests/reference/coreIndex_LoadSave_tagsIndex.json
@@ -1,10 +1,10 @@
{
- "tagsIndex": {
+ "novelWriter.tagsIndex": {
"Bod": {"handle": "4c4f28287af27", "heading": "T0001", "class": "CHARACTER"},
"Main": {"handle": "2426c6f0ca922", "heading": "T0001", "class": "PLOT"},
"Europe": {"handle": "04468803b92e1", "heading": "T0001", "class": "WORLD"}
},
- "itemIndex": {
+ "novelWriter.itemIndex": {
"7a992350f3eb6": {
"headings": {
"T0001": {"level": "H1", "title": "Lorem Ipsum", "line": 1, "tag": "", "cCount": 230, "wCount": 40, "pCount": 3, "synopsis": ""}
diff --git a/tests/test_core/test_core_index.py b/tests/test_core/test_core_index.py
index 5fada997..317e176b 100644
--- a/tests/test_core/test_core_index.py
+++ b/tests/test_core/test_core_index.py
@@ -125,7 +125,7 @@ def testCoreIndex_LoadSave(monkeypatch, prjLipsum, mockGUI, tstPaths):
assert theIndex.indexBroken is True
# Write an index file that passes loading, but is still empty
- writeFile(projFile, '{"tagsIndex": {}, "itemIndex": {}}')
+ writeFile(projFile, '{"novelWriter.tagsIndex": {}, "novelWriter.itemIndex": {}}')
assert theIndex.loadIndex() is True
assert theIndex.indexBroken is False
@@ -1071,13 +1071,13 @@ def testCoreIndex_ItemIndex(mockGUI, fncPath, mockRnd):
assert nStruct[3][0] == uHandle
# Novel structure with root handle set
- nStruct = list(itemIndex.iterNovelStructure(rootHandle=C.hNovelRoot))
+ nStruct = list(itemIndex.iterNovelStructure(rHandle=C.hNovelRoot))
assert len(nStruct) == 3
assert nStruct[0][0] == nHandle
assert nStruct[1][0] == cHandle
assert nStruct[2][0] == sHandle
- nStruct = list(itemIndex.iterNovelStructure(rootHandle=mHandle))
+ nStruct = list(itemIndex.iterNovelStructure(rHandle=mHandle))
assert len(nStruct) == 1
assert nStruct[0][0] == uHandle
From 0003f8c24dd861ffc686c0477e665ef6a3c6f017 Mon Sep 17 00:00:00 2001
From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com>
Date: Tue, 13 Jun 2023 17:31:16 +0200
Subject: [PATCH 09/19] Change format of wordlist.txt file to json
---
novelwriter/constants.py | 2 +-
novelwriter/core/spellcheck.py | 225 +++++++++++---------
novelwriter/core/storage.py | 32 ++-
novelwriter/dialogs/wordlist.py | 98 +++------
novelwriter/gui/doceditor.py | 7 +-
tests/test_core/test_core_spellcheck.py | 8 +-
tests/test_dialogs/test_dlg_wordlist.py | 3 +-
tests/test_tools/test_tools_writingstats.py | 3 +-
8 files changed, 200 insertions(+), 178 deletions(-)
diff --git a/novelwriter/constants.py b/novelwriter/constants.py
index 4a45f051..00e99713 100644
--- a/novelwriter/constants.py
+++ b/novelwriter/constants.py
@@ -90,7 +90,7 @@ class nwFiles:
INDEX_FILE = "index.json"
OPTS_FILE = "options.json"
PROJ_DICT = "wordlist.txt"
- SESS_STATS = "sessionStats.log"
+ DICT_FILE = "userdict.json"
SESS_FILE = "sessions.jsonl"
# END Class nwFiles
diff --git a/novelwriter/core/spellcheck.py b/novelwriter/core/spellcheck.py
index 884de15f..b5cac3a2 100644
--- a/novelwriter/core/spellcheck.py
+++ b/novelwriter/core/spellcheck.py
@@ -1,7 +1,6 @@
"""
novelWriter – Spell Check Classes
=================================
-Wrapper classes for spell checking tools
File History:
Created: 2019-06-11 [0.1.5]
@@ -22,29 +21,37 @@ General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
"""
+from __future__ import annotations
+import json
import logging
-from collections import namedtuple
+from typing import TYPE_CHECKING, Iterator
from pathlib import Path
from novelwriter.error import logException
+from novelwriter.constants import nwFiles
+
+if TYPE_CHECKING: # pragma: no cover
+ from novelwriter.core.project import NWProject
logger = logging.getLogger(__name__)
class NWSpellEnchant:
+ """Core: Enchant Spell Checking Wrapper
- def __init__(self):
-
- self._theDict = None
- self._projDict = set()
- self._projectDict = None
- self._spellLanguage = None
- self._theBroker = None
+ This is a rapper class for Enchant to keep the API consistent
+ between spell check tools.
+ """
+ def __init__(self, project: NWProject):
+ self._project = project
+ self._dictObj = FakeEnchant()
+ self._userDict = UserDictionary(project)
+ self._language = None
+ self._broker = None
logger.debug("Enchant spell checking activated")
-
return
##
@@ -52,43 +59,43 @@ class NWSpellEnchant:
##
@property
- def spellLanguage(self):
- return self._spellLanguage
+ def spellLanguage(self) -> str | None:
+ return self._language
##
# Setters
##
- def setLanguage(self, theLang, projectDict=None):
+ def setLanguage(self, language: str | None):
"""Load a dictionary for the language specified in the config.
If that fails, we load a mock dictionary so that lookups don't
crash. Note that enchant will allow loading an empty string as
a tag, but this will fail later on. See issue #1096.
"""
- self._theBroker = None
- self._theDict = None
- self._spellLanguage = None
+ self._dictObj = FakeEnchant()
+ self._broker = None
+ self._language = None
try:
import enchant
- if theLang and enchant.dict_exists(theLang):
- self._theBroker = enchant.Broker()
- self._theDict = self._theBroker.request_dict(theLang)
- self._spellLanguage = theLang
- logger.debug("Enchant spell checking for language '%s' loaded", theLang)
+ if language and enchant.dict_exists(language):
+ self._broker = enchant.Broker()
+ self._dictObj = self._broker.request_dict(language)
+ self._language = language
+ logger.debug("Enchant spell checking for language '%s' loaded", language)
else:
- logger.warning("Enchant found no dictionary for language '%s'", theLang)
+ logger.warning("Enchant found no dictionary for language '%s'", language)
except Exception:
- logger.error("Failed to load enchant spell checking for language '%s'", theLang)
+ logger.error("Failed to load enchant spell checking for language '%s'", language)
- if self._theDict is None:
- self._theDict = FakeEnchant()
+ if self._dictObj is None:
+ self._dictObj = FakeEnchant()
else:
- self._readProjectDictionary(projectDict)
- for pWord in self._projDict:
- self._theDict.add_to_session(pWord)
+ self._userDict.load()
+ for pWord in self._userDict:
+ self._dictObj.add_to_session(pWord)
return
@@ -96,47 +103,38 @@ class NWSpellEnchant:
# Methods
##
- def checkWord(self, theWord):
- """Wrapper function for pyenchant.
- """
+ def checkWord(self, word: str) -> bool:
+ """Wrapper function for pyenchant."""
try:
- return self._theDict.check(theWord)
+ return bool(self._dictObj.check(word))
except Exception:
return True
- def suggestWords(self, theWord):
- """Wrapper function for pyenchant.
- """
+ def suggestWords(self, word: str) -> list[str]:
+ """Wrapper function for pyenchant."""
try:
- return self._theDict.suggest(theWord)
+ return self._dictObj.suggest(word)
except Exception:
return []
- def addWord(self, newWord):
- """Add a word to the project dictionary.
- """
+ def addWord(self, word: str) -> bool:
+ """Add a word to the project dictionary."""
+ word = word.strip()
+ if not word:
+ return False
try:
- self._theDict.add_to_session(newWord)
+ self._dictObj.add_to_session(word)
except Exception:
return False
- if self._projectDict is not None and newWord not in self._projDict:
- newWord = newWord.strip()
- try:
- with open(self._projectDict, mode="a+", encoding="utf-8") as outFile:
- outFile.write("%s\n" % newWord)
- self._projDict.add(newWord)
- except Exception:
- logger.error("Failed to add word to project word list %s", str(self._projectDict))
- logException()
- return False
- return True
+ added = self._userDict.add(word)
+ if added:
+ self._userDict.save()
- return False
+ return added
- def listDictionaries(self):
- """Wrapper function for pyenchant.
- """
+ def listDictionaries(self) -> list[tuple[str, str]]:
+ """Wrapper function for pyenchant."""
retList = []
try:
import enchant
@@ -147,73 +145,98 @@ class NWSpellEnchant:
return retList
- def describeDict(self):
+ def describeDict(self) -> tuple[str, str]:
"""Return the tag and provider of the currently loaded
dictionary.
"""
try:
- spTag = self._theDict.tag
- spName = self._theDict.provider.name
+ tag = self._dictObj.tag
+ name = self._dictObj.provider.name # type: ignore
except Exception:
logger.error("Failed to extract information about the dictionary")
logException()
- spTag = ""
- spName = ""
+ tag = ""
+ name = ""
- return spTag, spName
-
- ##
- # Internal Functions
- ##
-
- def _readProjectDictionary(self, projectDict):
- """Read the content of the project dictionary, and add it to the
- lookup lists.
- """
- self._projDict = set()
- self._projectDict = projectDict
-
- if not isinstance(projectDict, Path):
- return False
-
- if not projectDict.exists():
- return False
-
- try:
- logger.debug("Loading project word list")
- with open(projectDict, mode="r", encoding="utf-8") as wordsFile:
- for theLine in wordsFile:
- theLine = theLine.strip()
- if len(theLine) > 0 and theLine not in self._projDict:
- self._projDict.add(theLine)
- logger.debug("Project word list contains %d words", len(self._projDict))
-
- except Exception:
- logger.error("Failed to load project word list")
- logException()
- return False
-
- return True
+ return tag, name
# END Class NWSpellEnchant
class FakeEnchant:
- """Fallback for when Enchant is selected, but not installed.
- """
+ """Fallback for when Enchant is selected, but not installed."""
def __init__(self):
+
+ class FakeProvider:
+ name = ""
+
self.tag = ""
- self.provider = namedtuple("provider", "name")
- self.provider.name = ""
+ self.provider = FakeProvider()
+
return
- def check(self, theWord):
+ def check(self, word: str) -> bool:
return True
- def suggest(self, theWord):
+ def suggest(self, word) -> list[str]:
return []
- def add_to_session(self, theWord):
+ def add_to_session(self, word: str):
return
# END Class FakeEnchant
+
+
+class UserDictionary:
+
+ def __init__(self, project: NWProject):
+ self._project = project
+ self._words = set()
+ self._path = None
+ return
+
+ def __contains__(self, word: str) -> bool:
+ return word in self._words
+
+ def __iter__(self) -> Iterator[str]:
+ return iter(self._words)
+
+ def add(self, word: str) -> bool:
+ """Add a word to the dictionary, and return True if it was
+ added, or False if it already existed.
+ """
+ if word in self._words:
+ return False
+ self._words.add(word)
+ return True
+
+ def load(self):
+ """Load the user's dictionary."""
+ self._path = self._project.storage.getMetaFile(nwFiles.DICT_FILE)
+ if not isinstance(self._path, Path):
+ return
+ try:
+ with open(self._path, mode="r", encoding="utf-8") as fObj:
+ data = json.load(fObj)
+ self._words = set(data.get("novelWriter.userDict", []))
+ except Exception:
+ logger.error("Failed to load user dictionary")
+ logException()
+ return
+
+ def save(self):
+ """Save the user's dictionary."""
+ if self._path is None:
+ self._path = self._project.storage.getMetaFile(nwFiles.DICT_FILE)
+ if not isinstance(self._path, Path):
+ return
+ try:
+ with open(self._path, mode="w", encoding="utf-8") as fObj:
+ data = {"novelWriter.userDict": list(self._words)}
+ json.dump(data, fObj, indent=2)
+ except Exception:
+ logger.error("Failed to save user dictionary")
+ logException()
+ return
+
+# END Class UserDictionary
diff --git a/novelwriter/core/storage.py b/novelwriter/core/storage.py
index 78d8d666..c56195bc 100644
--- a/novelwriter/core/storage.py
+++ b/novelwriter/core/storage.py
@@ -36,6 +36,7 @@ from novelwriter.common import minmax
from novelwriter.constants import nwFiles
from novelwriter.core.document import NWDocument
from novelwriter.core.projectxml import ProjectXMLReader, ProjectXMLWriter
+from novelwriter.core.spellcheck import UserDictionary
if TYPE_CHECKING: # pragma: no cover
from novelwriter.core.project import NWProject
@@ -253,7 +254,7 @@ class NWStorage:
(baseMeta / nwFiles.BUILDS_FILE, f"meta/{nwFiles.BUILDS_FILE}"),
(baseMeta / nwFiles.INDEX_FILE, f"meta/{nwFiles.INDEX_FILE}"),
(baseMeta / nwFiles.OPTS_FILE, f"meta/{nwFiles.OPTS_FILE}"),
- (baseMeta / nwFiles.PROJ_DICT, f"meta/{nwFiles.PROJ_DICT}"),
+ (baseMeta / nwFiles.DICT_FILE, f"meta/{nwFiles.DICT_FILE}"),
(baseMeta / nwFiles.SESS_FILE, f"meta/{nwFiles.SESS_FILE}"),
]
for contItem in baseCont.iterdir():
@@ -378,6 +379,10 @@ class NWStorage:
if sessLog.is_file():
self._convertOldLogFile(sessLog, path / "meta" / nwFiles.SESS_FILE)
+ wordList = path / "meta" / "wordlist.txt"
+ if wordList.is_file():
+ self._convertOldWordList(wordList)
+
remove = [
path / "meta" / "tagsIndex.json", # Renamed in 2.1 Beta 1
path / "meta" / "mainOptions.json", # Replaced in 0.5
@@ -413,6 +418,31 @@ class NWStorage:
return
+ def _convertOldWordList(self, wordList: Path) -> bool:
+ """Convert the old word list plain text file to new format."""
+ if not wordList.exists():
+ # Nothing to convert
+ return True
+
+ userDict = UserDictionary(self._project)
+ try:
+ with open(wordList, mode="r", encoding="utf-8") as fObj:
+ for line in fObj:
+ word = line.strip()
+ if word:
+ userDict.add(word)
+
+ # Dave dictionary and clean up old file
+ userDict.save()
+ wordList.unlink()
+
+ except Exception:
+ logger.error("Failed to convert old word list file")
+ logException()
+ return False
+
+ return True
+
def _convertOldLogFile(self, sessLog: Path, sessJson: Path) -> bool:
"""Convert the old text log file format to the new JSON Lines
format.
diff --git a/novelwriter/dialogs/wordlist.py b/novelwriter/dialogs/wordlist.py
index f2e7ddfe..118b94a4 100644
--- a/novelwriter/dialogs/wordlist.py
+++ b/novelwriter/dialogs/wordlist.py
@@ -1,7 +1,6 @@
"""
novelWriter – GUI User Wordlist
===============================
-Class holding the user's wordlist dialog
File History:
Created: 2021-02-12 [1.2rc1]
@@ -22,28 +21,31 @@ General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
"""
+from __future__ import annotations
import logging
-from pathlib import Path
+from typing import TYPE_CHECKING
from PyQt5.QtCore import Qt
from PyQt5.QtWidgets import (
- QDialog, QDialogButtonBox, QVBoxLayout, QHBoxLayout, QListWidget,
- QAbstractItemView, QPushButton, QLineEdit, QLabel
+ QAbstractItemView, QDialog, QDialogButtonBox, QHBoxLayout, QLabel,
+ QLineEdit, QListWidget, QListWidgetItem, QPushButton, QVBoxLayout
)
from novelwriter import CONFIG
from novelwriter.enum import nwAlert
-from novelwriter.error import logException
-from novelwriter.constants import nwFiles
+from novelwriter.core.spellcheck import UserDictionary
+
+if TYPE_CHECKING: # pragma: no cover
+ from novelwriter.guimain import GuiMain
logger = logging.getLogger(__name__)
class GuiWordList(QDialog):
- def __init__(self, mainGui):
+ def __init__(self, mainGui: GuiMain):
super().__init__(parent=mainGui)
logger.debug("Create: GuiWordList")
@@ -120,67 +122,49 @@ class GuiWordList(QDialog):
# Slots
##
- def _doAdd(self):
- """Add a new word to the word list.
- """
- newWord = self.newEntry.text().strip()
- if newWord == "":
+ def _doAdd(self) -> bool:
+ """Add a new word to the word list."""
+ word = self.newEntry.text().strip()
+ if word == "":
self.mainGui.makeAlert(self.tr(
"Cannot add a blank word."
), nwAlert.ERROR)
return False
- if self.listBox.findItems(newWord, Qt.MatchExactly):
+ if self.listBox.findItems(word, Qt.MatchExactly):
self.mainGui.makeAlert(self.tr(
"The word '{0}' is already in the word list."
- ).format(newWord), nwAlert.ERROR)
+ ).format(word), nwAlert.ERROR)
return False
- self.listBox.addItem(newWord)
+ self.listBox.addItem(word)
self.newEntry.setText("")
return True
def _doDelete(self):
- """Delete the selected item.
- """
+ """Delete the selected item."""
selItem = self.listBox.selectedItems()
if selItem:
self.listBox.takeItem(self.listBox.row(selItem[0]))
return
def _doSave(self):
- """Save the new word list and close.
- """
+ """Save the new word list and close."""
self._saveGuiSettings()
-
- dctFile = self.theProject.storage.getMetaFile(nwFiles.PROJ_DICT)
- if not isinstance(dctFile, Path):
- return False
-
- tmpFile = dctFile.with_suffix(".tmp")
- try:
- with open(tmpFile, mode="w", encoding="utf-8") as outFile:
- for i in range(self.listBox.count()):
- item = self.listBox.item(i)
- if item is not None:
- outFile.write(item.text() + "\n")
-
- tmpFile.replace(dctFile)
-
- except Exception:
- logger.error("Could not save new word list")
- logException()
- self.reject()
- return False
-
+ userDict = UserDictionary(self.theProject)
+ for i in range(self.listBox.count()):
+ item = self.listBox.item(i)
+ if isinstance(item, QListWidgetItem):
+ word = item.text().strip()
+ if word:
+ userDict.add(word)
+ userDict.save()
self.accept()
-
return True
def _doClose(self):
- """Close without saving the word list.
- """
+ """Close without saving the word list."""
self._saveGuiSettings()
self.reject()
return
@@ -190,29 +174,17 @@ class GuiWordList(QDialog):
##
def _loadWordList(self):
- """Load the project's word list, if it exists.
- """
- wordList = self.theProject.storage.getMetaFile(nwFiles.PROJ_DICT)
- if not isinstance(wordList, Path):
- return False
-
+ """Load the project's word list, if it exists."""
+ userDict = UserDictionary(self.theProject)
+ userDict.load()
self.listBox.clear()
- if not wordList.exists():
- logger.debug("No project dictionary file found")
- return False
-
- with open(wordList, mode="r", encoding="utf-8") as inFile:
- for inLine in inFile:
- theWord = inLine.strip()
- if len(theWord) == 0:
- continue
- self.listBox.addItem(theWord)
-
- return True
+ for word in userDict:
+ if word:
+ self.listBox.addItem(word)
+ return
def _saveGuiSettings(self):
- """Save GUI settings.
- """
+ """Save GUI settings."""
winWidth = CONFIG.rpxInt(self.width())
winHeight = CONFIG.rpxInt(self.height())
diff --git a/novelwriter/gui/doceditor.py b/novelwriter/gui/doceditor.py
index 499ddf2a..1074e549 100644
--- a/novelwriter/gui/doceditor.py
+++ b/novelwriter/gui/doceditor.py
@@ -52,7 +52,7 @@ from PyQt5.QtWidgets import (
from novelwriter import CONFIG
from novelwriter.enum import nwAlert, nwDocAction, nwDocInsert, nwDocMode, nwItemClass
from novelwriter.common import minmax, transferCase
-from novelwriter.constants import nwConst, nwFiles, nwKeyWords, nwUnicode
+from novelwriter.constants import nwConst, nwKeyWords, nwUnicode
from novelwriter.core.index import countWords
from novelwriter.core.spellcheck import NWSpellEnchant
from novelwriter.gui.dochighlight import GuiDocHighlighter
@@ -131,7 +131,7 @@ class GuiDocEditor(QTextEdit):
self.docSearch = GuiDocEditSearch(self)
# Syntax
- self.spEnchant = NWSpellEnchant()
+ self.spEnchant = NWSpellEnchant(self.theProject)
self.highLight = GuiDocHighlighter(qDoc, self.mainGui, self.spEnchant)
# Context Menu
@@ -703,8 +703,7 @@ class GuiDocEditor(QTextEdit):
else:
theLang = self.theProject.data.spellLang
- projDict = self.theProject.storage.getMetaFile(nwFiles.PROJ_DICT)
- self.spEnchant.setLanguage(theLang, projDict)
+ self.spEnchant.setLanguage(theLang)
_, theProvider = self.spEnchant.describeDict()
self.spellDictionaryChanged.emit(str(theLang), str(theProvider))
diff --git a/tests/test_core/test_core_spellcheck.py b/tests/test_core/test_core_spellcheck.py
index 1782adb4..9e06c803 100644
--- a/tests/test_core/test_core_spellcheck.py
+++ b/tests/test_core/test_core_spellcheck.py
@@ -37,18 +37,18 @@ def testCoreSpell_FakeEnchant(monkeypatch):
mp.setitem(sys.modules, "enchant", None)
spChk = NWSpellEnchant()
spChk.setLanguage("en_US", "")
- assert isinstance(spChk._theDict, FakeEnchant)
+ assert isinstance(spChk._dictObj, FakeEnchant)
# Request a non-existent dictionary
spChk = NWSpellEnchant()
spChk.setLanguage("whatchamajig", "")
- assert isinstance(spChk._theDict, FakeEnchant)
+ assert isinstance(spChk._dictObj, FakeEnchant)
# Request an emety language string
# See issue https://github.com/vkbo/novelWriter/issues/1096
spChk = NWSpellEnchant()
spChk.setLanguage("", "")
- assert isinstance(spChk._theDict, FakeEnchant)
+ assert isinstance(spChk._dictObj, FakeEnchant)
# FakeEnchant should handle requests
fkChk = FakeEnchant()
@@ -96,7 +96,7 @@ def testCoreSpell_Enchant(monkeypatch, fncPath):
assert spChk._readProjectDictionary(None) is False
assert spChk._readProjectDictionary(wList) is True
- assert spChk._projectDict == wList
+ assert spChk._userDictPath == wList
# Cannot write to file
with monkeypatch.context() as mp:
diff --git a/tests/test_dialogs/test_dlg_wordlist.py b/tests/test_dialogs/test_dlg_wordlist.py
index 8781a82d..dfc4280f 100644
--- a/tests/test_dialogs/test_dlg_wordlist.py
+++ b/tests/test_dialogs/test_dlg_wordlist.py
@@ -27,7 +27,6 @@ from PyQt5.QtWidgets import QDialog, QAction
from tools import buildTestProject, writeFile, readFile, getGuiItem
from mocked import causeOSError
-from novelwriter.constants import nwFiles
from novelwriter.dialogs.wordlist import GuiWordList
@@ -43,7 +42,7 @@ def testDlgWordList_Dialog(qtbot, monkeypatch, nwGUI, projPath):
# Open project
nwGUI.openProject(projPath)
- dictFile = projPath / "meta" / nwFiles.PROJ_DICT
+ dictFile = projPath / "meta" / "wordlist.txt"
# Load the dialog
nwGUI.mainMenu.aEditWordList.activate(QAction.Trigger)
diff --git a/tests/test_tools/test_tools_writingstats.py b/tests/test_tools/test_tools_writingstats.py
index af2e4f0c..8ef82fcb 100644
--- a/tests/test_tools/test_tools_writingstats.py
+++ b/tests/test_tools/test_tools_writingstats.py
@@ -28,7 +28,6 @@ from tools import getGuiItem, writeFile, buildTestProject
from PyQt5.QtCore import Qt
from PyQt5.QtWidgets import QAction, QFileDialog
-from novelwriter.constants import nwFiles
from novelwriter.tools.writingstats import GuiWritingStats
@@ -40,7 +39,7 @@ def testToolWritingStats_Main(qtbot, monkeypatch, nwGUI, projPath, tstPaths):
buildTestProject(nwGUI, projPath)
qtbot.wait(100)
assert nwGUI.saveProject()
- sessFile = projPath / "meta" / nwFiles.SESS_STATS
+ sessFile = projPath / "meta" / "sessionStats.log"
# Open the Writing Stats dialog
nwGUI.mainMenu.aWritingStats.activate(QAction.Trigger)
From 8d38f27eee59f78b703998cb30ecdac35bbc8231 Mon Sep 17 00:00:00 2001
From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com>
Date: Tue, 13 Jun 2023 17:31:40 +0200
Subject: [PATCH 10/19] Update gui options file format to macth others
---
novelwriter/core/options.py | 52 +++++++++++++++-------------
novelwriter/gui/outline.py | 22 +++++++-----
tests/test_core/test_core_options.py | 4 +--
3 files changed, 42 insertions(+), 36 deletions(-)
diff --git a/novelwriter/core/options.py b/novelwriter/core/options.py
index ad1a0aee..9409f743 100644
--- a/novelwriter/core/options.py
+++ b/novelwriter/core/options.py
@@ -79,7 +79,7 @@ class OptionState:
def __init__(self, project: NWProject):
self._project = project
- self._theState = {}
+ self._state = {}
return
##
@@ -93,24 +93,25 @@ class OptionState:
if not isinstance(stateFile, Path):
return False
- theState = {}
+ data = {}
if stateFile.exists():
logger.debug("Loading GUI options file")
try:
with open(stateFile, mode="r", encoding="utf-8") as inFile:
- theState = json.load(inFile)
+ data = json.load(inFile)
except Exception:
logger.error("Failed to load GUI options file")
logException()
return False
# Filter out unused variables
- for aGroup in theState:
+ state = data.get("novelWriter.guiOptions", {})
+ for aGroup in state:
if aGroup in VALID_MAP:
- self._theState[aGroup] = {}
- for anOpt in theState[aGroup]:
+ self._state[aGroup] = {}
+ for anOpt in state[aGroup]:
if anOpt in VALID_MAP[aGroup]:
- self._theState[aGroup][anOpt] = theState[aGroup][anOpt]
+ self._state[aGroup][anOpt] = state[aGroup][anOpt]
return True
@@ -123,7 +124,8 @@ class OptionState:
logger.debug("Saving GUI options file")
try:
with open(stateFile, mode="w+", encoding="utf-8") as fObj:
- fObj.write(jsonEncode(self._theState, nmax=3))
+ data = {"novelWriter.guiOptions": self._state}
+ fObj.write(jsonEncode(data, nmax=4))
except Exception:
logger.error("Failed to save GUI options file")
logException()
@@ -145,13 +147,13 @@ class OptionState:
logger.error("Unknown option name '%s'", name)
return False
- if group not in self._theState:
- self._theState[group] = {}
+ if group not in self._state:
+ self._state[group] = {}
if isinstance(value, Enum):
- self._theState[group][name] = value.name
+ self._state[group][name] = value.name
else:
- self._theState[group][name] = value
+ self._state[group][name] = value
return True
@@ -163,40 +165,40 @@ class OptionState:
"""Return an arbitrary type value, if it exists. Otherwise,
return the default value.
"""
- if group in self._theState:
- return self._theState[group].get(name, default)
+ if group in self._state:
+ return self._state[group].get(name, default)
return default
def getString(self, group: str, name: str, default: str) -> str:
"""Return the value as a string, if it exists. Otherwise, return
the default value.
"""
- if group in self._theState:
- return checkString(self._theState[group].get(name, default), default)
+ if group in self._state:
+ return checkString(self._state[group].get(name, default), default)
return default
def getInt(self, group: str, name: str, default: int) -> int:
"""Return the value as an int, if it exists. Otherwise, return
the default value.
"""
- if group in self._theState:
- return checkInt(self._theState[group].get(name, default), default)
+ if group in self._state:
+ return checkInt(self._state[group].get(name, default), default)
return default
def getFloat(self, group: str, name: str, default: float) -> float:
"""Return the value as a float, if it exists. Otherwise, return
the default value.
"""
- if group in self._theState:
- return checkFloat(self._theState[group].get(name, default), default)
+ if group in self._state:
+ return checkFloat(self._state[group].get(name, default), default)
return default
def getBool(self, group: str, name: str, default: bool) -> bool:
"""Return the value as a bool, if it exists. Otherwise, return
the default value.
"""
- if group in self._theState:
- return checkBool(self._theState[group].get(name, default), default)
+ if group in self._state:
+ return checkBool(self._state[group].get(name, default), default)
return default
def getEnum(self, group: str, name: str, lookup: type, default: Enum) -> Enum:
@@ -204,9 +206,9 @@ class OptionState:
default value.
"""
if issubclass(lookup, Enum):
- if group in self._theState:
- if name in self._theState[group]:
- value = self._theState[group][name]
+ if group in self._state:
+ if name in self._state[group]:
+ value = self._state[group][name]
if value in lookup.__members__:
return lookup[value]
return default
diff --git a/novelwriter/gui/outline.py b/novelwriter/gui/outline.py
index aa0828c1..0e4982fc 100644
--- a/novelwriter/gui/outline.py
+++ b/novelwriter/gui/outline.py
@@ -47,6 +47,7 @@ from novelwriter.enum import (
)
from novelwriter.common import checkInt
from novelwriter.constants import nwHeaders, trConst, nwKeyWords, nwLabels
+from novelwriter.error import logException
from novelwriter.gui.components import NovelSelector
@@ -577,20 +578,23 @@ class GuiOutlineTree(QTreeWidget):
and column width.
"""
# Load whatever we saved last time, regardless of wether it
- # contains the correct names or number of columns. The names
- # must be valid though.
+ # contains the correct names or number of columns.
colState = self.theProject.options.getValue("GuiOutline", "columnState", {})
tmpOrder = []
tmpHidden = {}
tmpWidth = {}
- for name, (hidden, width) in colState.items():
- if name not in nwOutline.__members__:
- logger.warning("Ignored unknown outline column '%s'", str(name))
- continue
- tmpOrder.append(nwOutline[name])
- tmpHidden[nwOutline[name]] = hidden
- tmpWidth[nwOutline[name]] = CONFIG.pxInt(width)
+ try:
+ for name, (hidden, width) in colState.items():
+ if name not in nwOutline.__members__:
+ logger.warning("Ignored unknown outline column '%s'", str(name))
+ continue
+ tmpOrder.append(nwOutline[name])
+ tmpHidden[nwOutline[name]] = hidden
+ tmpWidth[nwOutline[name]] = CONFIG.pxInt(width)
+ except Exception:
+ logger.error("Invalid column state")
+ logException()
# Add columns that was not in the file to the treeOrder array.
for hItem in nwOutline:
diff --git a/tests/test_core/test_core_options.py b/tests/test_core/test_core_options.py
index c241504e..6c4a3472 100644
--- a/tests/test_core/test_core_options.py
+++ b/tests/test_core/test_core_options.py
@@ -73,7 +73,7 @@ def testCoreOptions_LoadSave(monkeypatch, mockGUI, fncPath):
assert theOpts.loadSettings()
# Check that unwanted items have been removed
- assert theOpts._theState == {
+ assert theOpts._state == {
"GuiProjectSettings": {
"winWidth": 570,
"winHeight": 375,
@@ -88,7 +88,7 @@ def testCoreOptions_LoadSave(monkeypatch, mockGUI, fncPath):
# Load again to check we get the values back
assert theOpts.loadSettings()
- assert theOpts._theState == {
+ assert theOpts._state == {
"GuiProjectSettings": {
"winWidth": 570,
"winHeight": 375,
From f33ba03a2f75bfea264315fb03c49470e88f8026 Mon Sep 17 00:00:00 2001
From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com>
Date: Tue, 13 Jun 2023 21:10:11 +0200
Subject: [PATCH 11/19] Fix test coverage of wordlist dialog
---
novelwriter/dialogs/about.py | 2 +-
novelwriter/dialogs/docmerge.py | 2 +-
novelwriter/dialogs/docsplit.py | 2 +-
novelwriter/dialogs/preferences.py | 2 +-
novelwriter/dialogs/projdetails.py | 2 +-
novelwriter/dialogs/projload.py | 2 +-
novelwriter/dialogs/projsettings.py | 2 +-
novelwriter/dialogs/updates.py | 2 +-
novelwriter/dialogs/wordlist.py | 10 ++---
novelwriter/tools/lipsum.py | 2 +-
novelwriter/tools/manusbuild.py | 3 +-
novelwriter/tools/manuscript.py | 3 +-
novelwriter/tools/manussettings.py | 3 +-
novelwriter/tools/projwizard.py | 2 +-
novelwriter/tools/writingstats.py | 2 +-
tests/conftest.py | 12 ++---
tests/test_dialogs/test_dlg_wordlist.py | 59 ++++++++++++-------------
17 files changed, 52 insertions(+), 60 deletions(-)
diff --git a/novelwriter/dialogs/about.py b/novelwriter/dialogs/about.py
index d2f7d512..36b8bc99 100644
--- a/novelwriter/dialogs/about.py
+++ b/novelwriter/dialogs/about.py
@@ -114,7 +114,7 @@ class GuiAbout(QDialog):
return
- def __del__(self):
+ def __del__(self): # pragma: no cover
logger.debug("Delete: GuiAbout")
return
diff --git a/novelwriter/dialogs/docmerge.py b/novelwriter/dialogs/docmerge.py
index 71111f0a..49e3d5f1 100644
--- a/novelwriter/dialogs/docmerge.py
+++ b/novelwriter/dialogs/docmerge.py
@@ -113,7 +113,7 @@ class GuiDocMerge(QDialog):
return
- def __del__(self):
+ def __del__(self): # pragma: no cover
logger.debug("Delete: GuiDocMerge")
return
diff --git a/novelwriter/dialogs/docsplit.py b/novelwriter/dialogs/docsplit.py
index c08c017b..58396879 100644
--- a/novelwriter/dialogs/docsplit.py
+++ b/novelwriter/dialogs/docsplit.py
@@ -142,7 +142,7 @@ class GuiDocSplit(QDialog):
return
- def __del__(self):
+ def __del__(self): # pragma: no cover
logger.debug("Delete: GuiDocSplit")
return
diff --git a/novelwriter/dialogs/preferences.py b/novelwriter/dialogs/preferences.py
index 5d89cadc..9878ad51 100644
--- a/novelwriter/dialogs/preferences.py
+++ b/novelwriter/dialogs/preferences.py
@@ -87,7 +87,7 @@ class GuiPreferences(NPagedDialog):
return
- def __del__(self):
+ def __del__(self): # pragma: no cover
logger.debug("Delete: GuiPreferences")
return
diff --git a/novelwriter/dialogs/projdetails.py b/novelwriter/dialogs/projdetails.py
index d4cff393..e4960b57 100644
--- a/novelwriter/dialogs/projdetails.py
+++ b/novelwriter/dialogs/projdetails.py
@@ -82,7 +82,7 @@ class GuiProjectDetails(NPagedDialog):
return
- def __del__(self):
+ def __del__(self): # pragma: no cover
logger.debug("Delete: GuiProjectDetails")
return
diff --git a/novelwriter/dialogs/projload.py b/novelwriter/dialogs/projload.py
index 8fa3ec7a..04cef031 100644
--- a/novelwriter/dialogs/projload.py
+++ b/novelwriter/dialogs/projload.py
@@ -151,7 +151,7 @@ class GuiProjectLoad(QDialog):
return
- def __del__(self):
+ def __del__(self): # pragma: no cover
logger.debug("Delete: GuiProjectLoad")
return
diff --git a/novelwriter/dialogs/projsettings.py b/novelwriter/dialogs/projsettings.py
index aac956cc..f496b5e8 100644
--- a/novelwriter/dialogs/projsettings.py
+++ b/novelwriter/dialogs/projsettings.py
@@ -97,7 +97,7 @@ class GuiProjectSettings(NPagedDialog):
return
- def __del__(self):
+ def __del__(self): # pragma: no cover
logger.debug("Delete: GuiProjectSettings")
return
diff --git a/novelwriter/dialogs/updates.py b/novelwriter/dialogs/updates.py
index 15e40f8e..4b8afad1 100644
--- a/novelwriter/dialogs/updates.py
+++ b/novelwriter/dialogs/updates.py
@@ -117,7 +117,7 @@ class GuiUpdates(QDialog):
return
- def __del__(self):
+ def __del__(self): # pragma: no cover
logger.debug("Delete: GuiUpdates")
return
diff --git a/novelwriter/dialogs/wordlist.py b/novelwriter/dialogs/wordlist.py
index 118b94a4..55503376 100644
--- a/novelwriter/dialogs/wordlist.py
+++ b/novelwriter/dialogs/wordlist.py
@@ -114,7 +114,7 @@ class GuiWordList(QDialog):
return
- def __del__(self):
+ def __del__(self): # pragma: no cover
logger.debug("Delete: GuiWordList")
return
@@ -122,25 +122,25 @@ class GuiWordList(QDialog):
# Slots
##
- def _doAdd(self) -> bool:
+ def _doAdd(self):
"""Add a new word to the word list."""
word = self.newEntry.text().strip()
if word == "":
self.mainGui.makeAlert(self.tr(
"Cannot add a blank word."
), nwAlert.ERROR)
- return False
+ return
if self.listBox.findItems(word, Qt.MatchExactly):
self.mainGui.makeAlert(self.tr(
"The word '{0}' is already in the word list."
).format(word), nwAlert.ERROR)
- return False
+ return
self.listBox.addItem(word)
self.newEntry.setText("")
- return True
+ return
def _doDelete(self):
"""Delete the selected item."""
diff --git a/novelwriter/tools/lipsum.py b/novelwriter/tools/lipsum.py
index af152397..5c6d41ca 100644
--- a/novelwriter/tools/lipsum.py
+++ b/novelwriter/tools/lipsum.py
@@ -111,7 +111,7 @@ class GuiLipsum(QDialog):
return
- def __del__(self):
+ def __del__(self): # pragma: no cover
logger.debug("Delete: GuiLipsum")
return
diff --git a/novelwriter/tools/manusbuild.py b/novelwriter/tools/manusbuild.py
index 5b21cdbf..8ba93af7 100644
--- a/novelwriter/tools/manusbuild.py
+++ b/novelwriter/tools/manusbuild.py
@@ -235,8 +235,7 @@ class GuiManuscriptBuild(QDialog):
return
- def __del__(self):
- """For debug use only."""
+ def __del__(self): # pragma: no cover
logger.debug("Delete: GuiManuscriptBuild")
return
diff --git a/novelwriter/tools/manuscript.py b/novelwriter/tools/manuscript.py
index 11729bce..b2344aac 100644
--- a/novelwriter/tools/manuscript.py
+++ b/novelwriter/tools/manuscript.py
@@ -196,8 +196,7 @@ class GuiManuscript(QDialog):
return
- def __del__(self):
- """For debug use only."""
+ def __del__(self): # pragma: no cover
logger.debug("Delete: GuiManuscript")
return
diff --git a/novelwriter/tools/manussettings.py b/novelwriter/tools/manussettings.py
index c9fd824c..903284a0 100644
--- a/novelwriter/tools/manussettings.py
+++ b/novelwriter/tools/manussettings.py
@@ -167,8 +167,7 @@ class GuiBuildSettings(QDialog):
return
- def __del__(self):
- """For debug use only."""
+ def __del__(self): # pragma: no cover
logger.debug("Delete: GuiBuildSettings")
def loadContent(self):
diff --git a/novelwriter/tools/projwizard.py b/novelwriter/tools/projwizard.py
index 1fe6cdc2..f147dd0f 100644
--- a/novelwriter/tools/projwizard.py
+++ b/novelwriter/tools/projwizard.py
@@ -80,7 +80,7 @@ class GuiProjectWizard(QWizard):
return
- def __del__(self):
+ def __del__(self): # pragma: no cover
logger.debug("Delete: GuiProjectWizard")
return
diff --git a/novelwriter/tools/writingstats.py b/novelwriter/tools/writingstats.py
index d4223e60..ce6833bb 100644
--- a/novelwriter/tools/writingstats.py
+++ b/novelwriter/tools/writingstats.py
@@ -297,7 +297,7 @@ class GuiWritingStats(QDialog):
return
- def __del__(self):
+ def __del__(self): # pragma: no cover
logger.debug("Delete: GuiWritingStats")
return
diff --git a/tests/conftest.py b/tests/conftest.py
index a12bdecc..acb1ae19 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -61,8 +61,7 @@ def resetConfigVars():
@pytest.fixture(scope="session", autouse=True)
def sessionFixture():
- """A session wide fixture to set up the test environment.
- """
+ """A session wide fixture to set up the test environment."""
if _TMP_ROOT.exists():
shutil.rmtree(_TMP_ROOT)
_TMP_ROOT.mkdir()
@@ -111,8 +110,7 @@ def tstPaths():
@pytest.fixture(scope="function")
def fncPath():
- """A temporary folder for a single test function.
- """
+ """A temporary folder for a single test function."""
fncPath = _TMP_ROOT / "function"
if fncPath.is_dir():
shutil.rmtree(fncPath)
@@ -139,16 +137,14 @@ def projPath(fncPath):
@pytest.fixture(scope="function")
def mockGUI():
- """Create a mock instance of novelWriter's main GUI class.
- """
+ """Create a mock instance of novelWriter's main GUI class."""
theGui = MockGuiMain()
return theGui
@pytest.fixture(scope="function")
def nwGUI(qtbot, monkeypatch, functionFixture):
- """Create an instance of the novelWriter GUI.
- """
+ """Create an instance of the novelWriter GUI."""
monkeypatch.setattr(QMessageBox, "warning", lambda *a: QMessageBox.Ok)
monkeypatch.setattr(QMessageBox, "critical", lambda *a: QMessageBox.Ok)
monkeypatch.setattr(QMessageBox, "information", lambda *a: QMessageBox.Ok)
diff --git a/tests/test_dialogs/test_dlg_wordlist.py b/tests/test_dialogs/test_dlg_wordlist.py
index dfc4280f..8d698851 100644
--- a/tests/test_dialogs/test_dlg_wordlist.py
+++ b/tests/test_dialogs/test_dlg_wordlist.py
@@ -24,9 +24,9 @@ import pytest
from PyQt5.QtCore import Qt
from PyQt5.QtWidgets import QDialog, QAction
-from tools import buildTestProject, writeFile, readFile, getGuiItem
-from mocked import causeOSError
+from tools import buildTestProject, getGuiItem
+from novelwriter.core.spellcheck import UserDictionary
from novelwriter.dialogs.wordlist import GuiWordList
@@ -42,7 +42,6 @@ def testDlgWordList_Dialog(qtbot, monkeypatch, nwGUI, projPath):
# Open project
nwGUI.openProject(projPath)
- dictFile = projPath / "meta" / "wordlist.txt"
# Load the dialog
nwGUI.mainMenu.aEditWordList.activate(QAction.Trigger)
@@ -56,15 +55,15 @@ def testDlgWordList_Dialog(qtbot, monkeypatch, nwGUI, projPath):
assert wList.listBox.count() == 0
# Add words
- writeFile(dictFile, (
- "word_a\n"
- "word_c\n"
- "word_g\n"
- " \n" # Should be ignored
- "word_f\n"
- "word_b\n"
- ))
- assert wList._loadWordList()
+ userDict = UserDictionary(nwGUI.theProject)
+ userDict.add("word_a")
+ userDict.add("word_c")
+ userDict.add("word_g")
+ userDict.add("word_f")
+ userDict.add("word_b")
+ userDict.save()
+
+ wList._loadWordList()
# Check that the content was loaded
assert wList.listBox.item(0).text() == "word_a"
@@ -72,18 +71,22 @@ def testDlgWordList_Dialog(qtbot, monkeypatch, nwGUI, projPath):
assert wList.listBox.item(2).text() == "word_c"
assert wList.listBox.item(3).text() == "word_f"
assert wList.listBox.item(4).text() == "word_g"
+ assert wList.listBox.count() == 5
- # Add a blank word
+ # Add a blank word, which is ignored
wList.newEntry.setText(" ")
- assert not wList._doAdd()
+ wList._doAdd()
+ assert wList.listBox.count() == 5
- # Add an existing word
+ # Add an existing word, which is ignored
wList.newEntry.setText("word_c")
- assert not wList._doAdd()
+ wList._doAdd()
+ assert wList.listBox.count() == 5
# Add a new word
wList.newEntry.setText("word_d")
- assert wList._doAdd()
+ wList._doAdd()
+ assert wList.listBox.count() == 6
# Check that the content now
assert wList.listBox.item(0).text() == "word_a"
@@ -95,7 +98,7 @@ def testDlgWordList_Dialog(qtbot, monkeypatch, nwGUI, projPath):
# Delete a word
wList.newEntry.setText("delete_me")
- assert wList._doAdd()
+ wList._doAdd()
assert wList.listBox.item(0).text() == "delete_me"
delItem = wList.listBox.findItems("delete_me", Qt.MatchExactly)[0]
@@ -107,18 +110,14 @@ def testDlgWordList_Dialog(qtbot, monkeypatch, nwGUI, projPath):
# Save files
assert wList._doSave()
- assert readFile(dictFile) == (
- "word_a\n"
- "word_b\n"
- "word_c\n"
- "word_d\n"
- "word_f\n"
- "word_g\n"
- )
-
- # Save again and make it fail
- monkeypatch.setattr("builtins.open", causeOSError)
- assert not wList._doSave()
+ userDict.load()
+ assert len(list(userDict)) == 6
+ assert "word_a" in userDict
+ assert "word_b" in userDict
+ assert "word_c" in userDict
+ assert "word_d" in userDict
+ assert "word_f" in userDict
+ assert "word_g" in userDict
# qtbot.stop()
wList._doClose()
From b7bb2988e8fe43ef5ae18a8be7bc22236b91290b Mon Sep 17 00:00:00 2001
From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com>
Date: Tue, 13 Jun 2023 21:11:05 +0200
Subject: [PATCH 12/19] Fix test coverage of options class
---
tests/test_core/test_core_options.py | 20 +++++++++++---------
1 file changed, 11 insertions(+), 9 deletions(-)
diff --git a/tests/test_core/test_core_options.py b/tests/test_core/test_core_options.py
index 6c4a3472..dba4bdea 100644
--- a/tests/test_core/test_core_options.py
+++ b/tests/test_core/test_core_options.py
@@ -42,15 +42,17 @@ def testCoreOptions_LoadSave(monkeypatch, mockGUI, fncPath):
# Write a test file
optFile = metaDir / nwFiles.OPTS_FILE
optFile.write_text(json.dumps({
- "GuiProjectSettings": {
- "winWidth": 570,
- "winHeight": 375,
- "replaceColW": 130,
- "statusColW": 130,
- "importColW": 130
- },
- "MockGroup": {
- "mockItem": None,
+ "novelWriter.guiOptions": {
+ "GuiProjectSettings": {
+ "winWidth": 570,
+ "winHeight": 375,
+ "replaceColW": 130,
+ "statusColW": 130,
+ "importColW": 130
+ },
+ "MockGroup": {
+ "mockItem": None,
+ },
},
}), encoding="utf-8")
From 26e6845439cf34f4bfd52ec6d282bd36bc332e99 Mon Sep 17 00:00:00 2001
From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com>
Date: Tue, 13 Jun 2023 21:54:06 +0200
Subject: [PATCH 13/19] Fix test coverage of spell check classes
---
tests/test_core/test_core_project.py | 47 +----
tests/test_core/test_core_spellcheck.py | 225 ++++++++++++++----------
2 files changed, 131 insertions(+), 141 deletions(-)
diff --git a/tests/test_core/test_core_project.py b/tests/test_core/test_core_project.py
index 39a33517..2355cd4f 100644
--- a/tests/test_core/test_core_project.py
+++ b/tests/test_core/test_core_project.py
@@ -21,9 +21,7 @@ along with this program. If not, see .
import pytest
-from time import time
from shutil import copyfile
-from pathlib import Path
from zipfile import ZipFile
from mocked import causeOSError
@@ -31,8 +29,6 @@ from tools import C, cmpFiles, writeFile, buildTestProject, XML_IGNORE
from novelwriter import CONFIG
from novelwriter.enum import nwItemClass, nwItemType, nwItemLayout
-from novelwriter.common import formatTimeStamp
-from novelwriter.constants import nwFiles
from novelwriter.core.tree import NWTree
from novelwriter.core.index import NWIndex
from novelwriter.core.project import NWProject
@@ -468,8 +464,7 @@ def testCoreProject_StatusImport(mockGUI, fncPath, mockRnd):
@pytest.mark.core
def testCoreProject_Methods(monkeypatch, mockGUI, fncPath, mockRnd):
- """Test other project class methods and functions.
- """
+ """Test other project class methods and functions."""
theProject = NWProject(mockGUI)
buildTestProject(theProject, fncPath)
@@ -578,46 +573,6 @@ def testCoreProject_Methods(monkeypatch, mockGUI, fncPath, mockRnd):
assert theProject.setTreeOrder(oldOrder)
assert theProject.tree.handles() == oldOrder
- # Session stats
- theProject.data.setInitCounts(50, 50)
- theProject.data.setCurrCounts(100, 100)
-
- # No path for writing
- with monkeypatch.context() as mp:
- mp.setattr("novelwriter.core.storage.NWStorage.getMetaFile", lambda *a: None)
- assert theProject.session.appendSession(idleTime=0) is False
-
- # Block open
- with monkeypatch.context() as mp:
- mp.setattr("builtins.open", causeOSError)
- assert theProject.session.appendSession(idleTime=0) is False
-
- # Session too short
- theProject._session._start = time()
- theProject.data.setInitCounts(50, 50)
- theProject.data.setCurrCounts(50, 50)
- assert theProject.session.appendSession(idleTime=0) is False
-
- # Write entry
- statsFile = theProject.storage.getMetaFile(nwFiles.SESS_FILE)
- assert isinstance(statsFile, Path)
- if statsFile.exists():
- statsFile.unlink()
-
- theProject._session._start = 1600002000
- theProject.data._initCounts = [50, 50]
- theProject.data._currCounts = [200, 100]
-
- with monkeypatch.context() as mp:
- mp.setattr("novelwriter.core.project.time", lambda: 1600005600)
- assert theProject.session.appendSession(idleTime=99)
-
- assert statsFile.read_text(encoding="utf-8") == (
- "# Offset 100\n"
- "# Start Time End Time Novel Notes Idle\n"
- "%s %s 200 100 99\n"
- ) % (formatTimeStamp(1600002000), formatTimeStamp(1600005600))
-
# END Test testCoreProject_Methods
diff --git a/tests/test_core/test_core_spellcheck.py b/tests/test_core/test_core_spellcheck.py
index 9e06c803..9f2e59de 100644
--- a/tests/test_core/test_core_spellcheck.py
+++ b/tests/test_core/test_core_spellcheck.py
@@ -21,33 +21,118 @@ along with this program. If not, see .
import sys
import pytest
+import enchant
+from pathlib import Path
+
+from tools import buildTestProject
from mocked import causeOSError
-from tools import readFile, writeFile
-from novelwriter.core.spellcheck import FakeEnchant, NWSpellEnchant
+from novelwriter.constants import nwFiles
+from novelwriter.core.project import NWProject
+from novelwriter.core.spellcheck import FakeEnchant, NWSpellEnchant, UserDictionary
@pytest.mark.core
-def testCoreSpell_FakeEnchant(monkeypatch):
- """Test the FakeEnchant spell checker fallback.
- """
+def testCoreSpell_UserDictionary(monkeypatch, mockGUI, fncPath):
+ """Test the UserDictionary class."""
+ project = NWProject(mockGUI)
+ buildTestProject(project, fncPath)
+
+ # Check that there is no file before we start
+ dictFile = project.storage.getMetaFile(nwFiles.DICT_FILE)
+ assert isinstance(dictFile, Path)
+ assert not dictFile.exists()
+
+ # Add a couple of words
+ userDict = UserDictionary(project)
+ assert userDict.add("foo") is True
+ assert userDict.add("bar") is True
+ assert userDict.add("bar") is False # No duplicates
+
+ # Check that we have them
+ assert "foo" in userDict
+ assert "bar" in userDict
+
+ # Check the iterator
+ assert sorted(userDict) == ["bar", "foo"]
+
+ # Save the file, but fail
+ assert userDict._path is None
+ with monkeypatch.context() as mp:
+ mp.setattr("builtins.open", causeOSError)
+ userDict.save()
+
+ # There should be no file, but the file path should now be cached
+ assert userDict._path == dictFile
+ assert not dictFile.exists()
+
+ # Break the path check
+ userDict._path = None
+ with monkeypatch.context() as mp:
+ mp.setattr("novelwriter.core.storage.NWStorage.getMetaFile", lambda *a: None)
+ userDict.save()
+
+ # There should still be no file
+ assert not dictFile.exists()
+
+ # Save proper
+ userDict.save()
+ assert dictFile.exists()
+
+ # Clear the dictionary
+ userDict._words = set()
+ assert sorted(userDict) == []
+
+ # Load the file, but fail
+ userDict._path = None
+ with monkeypatch.context() as mp:
+ mp.setattr("builtins.open", causeOSError)
+ userDict.load()
+
+ # Path is now set, but no words
+ assert userDict._path == dictFile
+ assert sorted(userDict) == []
+
+ # Break the path check
+ userDict._path = None
+ with monkeypatch.context() as mp:
+ mp.setattr("novelwriter.core.storage.NWStorage.getMetaFile", lambda *a: None)
+ userDict.load()
+
+ # Path is now None, and no words
+ assert userDict._path is None
+ assert sorted(userDict) == []
+
+ # Load the words again, properly
+ userDict.load()
+ assert sorted(userDict) == ["bar", "foo"]
+
+# END Test testCoreSpell_UserDictionary
+
+
+@pytest.mark.core
+def testCoreSpell_FakeEnchant(monkeypatch, mockGUI, fncPath):
+ """Test the FakeEnchant spell checker fallback."""
+ project = NWProject(mockGUI)
+ buildTestProject(project, fncPath)
+
# Make package import fail
with monkeypatch.context() as mp:
mp.setitem(sys.modules, "enchant", None)
- spChk = NWSpellEnchant()
- spChk.setLanguage("en_US", "")
+ spChk = NWSpellEnchant(project)
+ spChk.setLanguage("en_US")
assert isinstance(spChk._dictObj, FakeEnchant)
# Request a non-existent dictionary
- spChk = NWSpellEnchant()
- spChk.setLanguage("whatchamajig", "")
+ spChk = NWSpellEnchant(project)
+ spChk.setLanguage("whatchamajig")
assert isinstance(spChk._dictObj, FakeEnchant)
- # Request an emety language string
+ # Request an empty language string
# See issue https://github.com/vkbo/novelWriter/issues/1096
- spChk = NWSpellEnchant()
- spChk.setLanguage("", "")
+ spChk = NWSpellEnchant(project)
+ spChk.setLanguage("")
assert isinstance(spChk._dictObj, FakeEnchant)
# FakeEnchant should handle requests
@@ -62,103 +147,53 @@ def testCoreSpell_FakeEnchant(monkeypatch):
@pytest.mark.core
-def testCoreSpell_Enchant(monkeypatch, fncPath):
- """Test the pyenchant spell checker.
- """
- wList = fncPath / "wordlist.txt"
- writeFile(wList, "a_word\nb_word\nc_word\n")
+def testCoreSpell_Enchant(monkeypatch, mockGUI, fncPath):
+ """Test the pyenchant spell checker."""
+ project = NWProject(mockGUI)
+ buildTestProject(project, fncPath)
# Break the enchant package, and check error handling
with monkeypatch.context() as mp:
mp.setitem(sys.modules, "enchant", None)
- spChk = NWSpellEnchant()
+ spChk = NWSpellEnchant(project)
+ assert spChk.spellLanguage is None
assert spChk.listDictionaries() == []
assert spChk.describeDict() == ("", "")
- # Set the dict to None, and check dictionary call error handling
- spChk = NWSpellEnchant()
- spChk.theDict = None
+ spChk.setLanguage("en_US")
+ assert spChk.spellLanguage is None
+
+ # Check that the FakeEnchant class is actually handling this
+ assert isinstance(spChk._dictObj, FakeEnchant)
+ assert spChk.checkWord("word") is True
+ assert spChk.suggestWords("word") == []
+ assert spChk.addWord("word") is True
+
+ # Set the dict to None, and check enchant error handling
+ spChk = NWSpellEnchant(project)
+ spChk._dictObj = None # type: ignore
assert spChk.checkWord("word") is True
assert spChk.suggestWords("word") == []
assert spChk.addWord("word") is False
+ assert spChk.addWord("\n\t ") is False
+ assert spChk.describeDict() == ("", "")
# Load the proper enchant package (twice)
- spChk = NWSpellEnchant()
- spChk.setLanguage("en_US", wList)
- spChk.setLanguage("en_US", wList)
+ spChk = NWSpellEnchant(project)
+ spChk.setLanguage("en_US")
+ spChk.setLanguage("en_US")
+ assert isinstance(spChk._dictObj, enchant.Dict)
assert spChk.spellLanguage == "en_US"
+ assert spChk.listDictionaries() != []
+ assert spChk.describeDict() != ("", "")
- # Add a word to the user's dictionary
- assert spChk._readProjectDictionary("stuff") is False
+ # Set to non-existent language
+ spChk.setLanguage("foo_bar")
+
+ # Block the broker from figuring out the language
with monkeypatch.context() as mp:
- mp.setattr("builtins.open", causeOSError)
- assert spChk._readProjectDictionary(wList) is False
-
- assert spChk._readProjectDictionary(None) is False
- assert spChk._readProjectDictionary(wList) is True
- assert spChk._userDictPath == wList
-
- # Cannot write to file
- with monkeypatch.context() as mp:
- mp.setattr("builtins.open", causeOSError)
- assert spChk.addWord("d_word") is False
-
- assert readFile(wList) == "a_word\nb_word\nc_word\n"
- assert spChk.addWord("d_word") is True
- assert readFile(wList) == "a_word\nb_word\nc_word\nd_word\n"
- assert spChk.addWord("d_word") is False
-
- # Check words
- assert spChk.checkWord("a_word") is True
- assert spChk.checkWord("b_word") is True
- assert spChk.checkWord("c_word") is True
- assert spChk.checkWord("d_word") is True
- assert spChk.checkWord("e_word") is False
-
- spChk.addWord("d_word")
- assert spChk.checkWord("d_word") is True
-
- wSuggest = spChk.suggestWords("wrod")
- assert len(wSuggest) > 0
- assert "word" in wSuggest
-
- dList = spChk.listDictionaries()
- assert len(dList) > 0
-
- aTag, aName = spChk.describeDict()
- assert aTag == "en_US"
- assert aName != ""
+ mp.setattr("enchant.Broker.request_dict", lambda *a: None)
+ spChk.setLanguage("en_US")
+ assert isinstance(spChk._dictObj, FakeEnchant)
# END Test testCoreSpell_Enchant
-
-
-@pytest.mark.core
-def testCoreSpell_SessionWords(fncPath):
- """Test the handling of the custom word list in the spell checker.
- New project sessions should not inherit the project word list from
- other sessions, so this test checks that they don't bleed through.
- """
- wList1 = fncPath / "wordlist1.txt"
- wList2 = fncPath / "wordlist2.txt"
- writeFile(wList1, "a_word\nb_word\nc_word\n")
- writeFile(wList2, "d_word\ne_word\nf_word\n")
-
- spChk = NWSpellEnchant()
-
- spChk.setLanguage("en_US", wList1)
- assert spChk.checkWord("a_word") is True
- assert spChk.checkWord("b_word") is True
- assert spChk.checkWord("c_word") is True
- assert spChk.checkWord("d_word") is False
- assert spChk.checkWord("e_word") is False
- assert spChk.checkWord("f_word") is False
-
- spChk.setLanguage("en_US", wList2)
- assert spChk.checkWord("a_word") is False
- assert spChk.checkWord("b_word") is False
- assert spChk.checkWord("c_word") is False
- assert spChk.checkWord("d_word") is True
- assert spChk.checkWord("e_word") is True
- assert spChk.checkWord("f_word") is True
-
-# END Test testCoreSpell_SessionWords
From b2bfaad73dcb36d06d1c301b1967dcfb88acef35 Mon Sep 17 00:00:00 2001
From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com>
Date: Tue, 13 Jun 2023 22:15:14 +0200
Subject: [PATCH 14/19] Fix test coverage of outline class
---
novelwriter/gui/outline.py | 12 ++----
tests/test_gui/test_gui_outline.py | 61 +++++++++++++++++-------------
2 files changed, 38 insertions(+), 35 deletions(-)
diff --git a/novelwriter/gui/outline.py b/novelwriter/gui/outline.py
index 0e4982fc..fd40d8ff 100644
--- a/novelwriter/gui/outline.py
+++ b/novelwriter/gui/outline.py
@@ -601,15 +601,9 @@ class GuiOutlineTree(QTreeWidget):
if hItem not in tmpOrder:
tmpOrder.append(hItem)
- # Check that we now have a complete list, and only if so, save
- # the order loaded from file. Otherwise, we keep the default.
- if len(tmpOrder) == self._treeNCols:
- self._treeOrder = tmpOrder
- self._colHidden.update(tmpHidden)
- self._colWidth.update(tmpWidth)
- else:
- logger.error("Failed to extract outline column order from previous session")
- logger.error("Column count doesn't match %d != %d", len(tmpOrder), self._treeNCols)
+ self._treeOrder = tmpOrder
+ self._colHidden.update(tmpHidden)
+ self._colWidth.update(tmpWidth)
self.hiddenStateChanged.emit()
diff --git a/tests/test_gui/test_gui_outline.py b/tests/test_gui/test_gui_outline.py
index f208f246..5aaf3117 100644
--- a/tests/test_gui/test_gui_outline.py
+++ b/tests/test_gui/test_gui_outline.py
@@ -33,8 +33,7 @@ from novelwriter.enum import nwItemClass, nwOutline, nwView
@pytest.mark.gui
def testGuiOutline_Main(qtbot, monkeypatch, nwGUI, projPath):
- """Test the outline view.
- """
+ """Test the outline view."""
# Create a project
buildTestProject(nwGUI, projPath)
@@ -83,52 +82,61 @@ def testGuiOutline_Main(qtbot, monkeypatch, nwGUI, projPath):
# Save header state not allowed
outlineTree._lastBuild = 0
outlineTree._saveHeaderState()
- assert pOptions.getValue("GuiOutline", "headerOrder", []) == []
+ assert pOptions.getValue("GuiOutline", "columnState", {}) == {}
# Allow saving header state
outlineTree._lastBuild = time.time()
outlineTree._saveHeaderState()
- assert pOptions.getValue("GuiOutline", "headerOrder", []) == colNames
+ assert list(pOptions.getValue("GuiOutline", "columnState", {}).keys()) == colNames
assert outlineTree._treeOrder == colItems
assert outlineTree._colWidth == colWidth
assert outlineTree._colHidden == colHidden
# Get default values
- optItems = pOptions.getValue("GuiOutline", "headerOrder", [])
- optWidth = pOptions.getValue("GuiOutline", "columnWidth", {})
- optHidden = pOptions.getValue("GuiOutline", "columnHidden", {})
+ columnState = pOptions.getValue("GuiOutline", "columnState", {})
# Add invalid column name
- pOptions.setValue("GuiOutline", "headerOrder", optItems + ["blabla"])
- outlineTree._loadHeaderState()
- assert outlineTree._treeOrder == colItems
- assert outlineTree._colHidden == colHidden
-
- # Add duplicate column name
- pOptions.setValue("GuiOutline", "headerOrder", optItems + [optItems[-1]])
+ newState = columnState.copy()
+ newState.update({"blabla": (False, 42)})
+ pOptions.setValue("GuiOutline", "columnState", newState)
outlineTree._loadHeaderState()
assert outlineTree._treeOrder == colItems
assert outlineTree._colHidden == colHidden
# Invalid column width data
- pOptions.setValue("GuiOutline", "headerOrder", optItems)
- pOptions.setValue("GuiOutline", "columnWidth", {"blabla": None})
+ newState = columnState.copy()
+ newState.update({"TITLE": (False, None)})
+ pOptions.setValue("GuiOutline", "columnState", newState)
outlineTree._loadHeaderState()
assert outlineTree._treeOrder == colItems
assert outlineTree._colHidden == colHidden
- # Invalid column width data
- pOptions.setValue("GuiOutline", "headerOrder", optItems)
- pOptions.setValue("GuiOutline", "columnWidth", optWidth)
- pOptions.setValue("GuiOutline", "columnHidden", {"bloabla": None})
+ # Invalid column state data
+ newState = columnState.copy()
+ newState.update({"TITLE": None})
+ pOptions.setValue("GuiOutline", "columnState", newState)
outlineTree._loadHeaderState()
assert outlineTree._treeOrder == colItems
assert outlineTree._colHidden == colHidden
+ # Drop a few columns
+ newState = columnState.copy()
+ del newState[nwOutline.CHAR.name]
+ del newState[nwOutline.WORLD.name]
+ del newState[nwOutline.LINE.name]
+ pOptions.setValue("GuiOutline", "columnState", newState)
+ outlineTree._loadHeaderState()
+ assert len(outlineTree._treeOrder) == len(colItems)
+ assert len(outlineTree._colHidden) == len(colHidden)
+ assert nwOutline.CHAR in outlineTree._treeOrder
+ assert nwOutline.CHAR in outlineTree._colHidden
+ assert nwOutline.WORLD in outlineTree._treeOrder
+ assert nwOutline.WORLD in outlineTree._colHidden
+ assert nwOutline.LINE in outlineTree._treeOrder
+ assert nwOutline.LINE in outlineTree._colHidden
+
# Valid settings
- pOptions.setValue("GuiOutline", "headerOrder", optItems)
- pOptions.setValue("GuiOutline", "columnWidth", optWidth)
- pOptions.setValue("GuiOutline", "columnHidden", optHidden)
+ pOptions.setValue("GuiOutline", "columnState", columnState)
outlineTree._loadHeaderState()
assert outlineTree._treeOrder == colItems
assert outlineTree._colHidden == colHidden
@@ -143,7 +151,9 @@ def testGuiOutline_Main(qtbot, monkeypatch, nwGUI, projPath):
# Now no columns should be hidden
outlineTree._saveHeaderState()
- assert not any(pOptions.getValue("GuiOutline", "columnHidden", None).values())
+ hiddenStates = [v[0] for v in pOptions.getValue("GuiOutline", "columnState", {}).values()]
+ assert len(hiddenStates) == len(columnState)
+ assert not any(hiddenStates)
# qtbot.stop()
@@ -152,8 +162,7 @@ def testGuiOutline_Main(qtbot, monkeypatch, nwGUI, projPath):
@pytest.mark.gui
def testGuiOutline_Content(qtbot, nwGUI, prjLipsum):
- """Test the outline view.
- """
+ """Test the outline view."""
assert nwGUI.openProject(prjLipsum)
nwGUI.rebuildIndex()
From 90012aff1757ce2a8e5b3528000d050544dc295e Mon Sep 17 00:00:00 2001
From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com>
Date: Tue, 13 Jun 2023 22:32:17 +0200
Subject: [PATCH 15/19] Fix test coverage of writingstats class
---
novelwriter/tools/writingstats.py | 2 +-
tests/test_tools/test_tools_writingstats.py | 70 +++++++++------------
2 files changed, 31 insertions(+), 41 deletions(-)
diff --git a/novelwriter/tools/writingstats.py b/novelwriter/tools/writingstats.py
index ce6833bb..31f91ba7 100644
--- a/novelwriter/tools/writingstats.py
+++ b/novelwriter/tools/writingstats.py
@@ -471,7 +471,7 @@ class GuiWritingStats(QDialog):
self.notesWords.setText(f"{ttNotes:n}")
self.totalWords.setText(f"{ttWords:n}")
- return True
+ return
##
# Slots
diff --git a/tests/test_tools/test_tools_writingstats.py b/tests/test_tools/test_tools_writingstats.py
index 8ef82fcb..50ab2e1d 100644
--- a/tests/test_tools/test_tools_writingstats.py
+++ b/tests/test_tools/test_tools_writingstats.py
@@ -20,14 +20,16 @@ along with this program. If not, see .
"""
import json
+from pathlib import Path
import pytest
+from tools import getGuiItem, buildTestProject
from mocked import causeOSError
-from tools import getGuiItem, writeFile, buildTestProject
from PyQt5.QtCore import Qt
from PyQt5.QtWidgets import QAction, QFileDialog
+from novelwriter.constants import nwFiles
from novelwriter.tools.writingstats import GuiWritingStats
@@ -37,9 +39,11 @@ def testToolWritingStats_Main(qtbot, monkeypatch, nwGUI, projPath, tstPaths):
"""
# Create a project to work on
buildTestProject(nwGUI, projPath)
+ project = nwGUI.theProject
+
qtbot.wait(100)
assert nwGUI.saveProject()
- sessFile = projPath / "meta" / "sessionStats.log"
+ sessFile: Path = projPath / "meta" / nwFiles.SESS_FILE
# Open the Writing Stats dialog
nwGUI.mainMenu.aWritingStats.activate(QAction.Trigger)
@@ -53,52 +57,38 @@ def testToolWritingStats_Main(qtbot, monkeypatch, nwGUI, projPath, tstPaths):
# No initial logfile
assert not sessFile.is_file()
- assert not sessLog._loadLogFile()
+ assert list(project.session.iterRecords()) == []
# Make a test log file
- writeFile(sessFile, (
- "# Offset 123\n"
- "# Start Time End Time Novel Notes Idle\n"
- "2020-01-01 21:00:00 2020-01-01 21:00:05 6 0\n"
- "2020-01-03 21:00:00 2020-01-03 21:00:15 125 0\n"
- "2020-01-03 21:30:00 2020-01-03 21:30:15 125 5\n"
- "2020-01-06 21:00:00 2020-01-06 21:00:10 125 5\n"
- ))
- assert sessFile.is_file()
- assert sessLog._loadLogFile()
+ data = [
+ project.session.createInitial(123),
+ project.session.createRecord("2020-01-01 21:00:00", "2020-01-01 21:00:05", 6, 0, 0),
+ project.session.createRecord("2020-01-03 21:00:00", "2020-01-03 21:00:15", 125, 0, 0),
+ project.session.createRecord("2020-01-03 21:30:00", "2020-01-03 21:30:15", 125, 5, 0),
+ project.session.createRecord("2020-01-06 21:00:00", "2020-01-06 21:00:10", 125, 5, 0),
+ ]
+ sessFile.write_text("".join(data), encoding="utf-8")
+ sessLog._loadLogFile()
assert sessLog.wordOffset == 123
assert len(sessLog.logData) == 4
- # Make sure a faulty file can still be read
- writeFile(sessFile, (
- "# Offset abc123\n"
- "# Start Time End Time Novel Notes Idle\n"
- "2020-01-01 21:00:00 2020-01-01 21:00:05 6 0 50\n"
- "2020-01-03 21:00:00 2020-01-03 21:00:15 125 0\n"
- "2020-01-03 21:30:00 2020-01-03 21:30:15 125 5\n"
- "2020-01-06 21:00:00 2020-01-06 21:00:10 125\n"
- ))
- assert sessLog._loadLogFile()
- assert sessLog.wordOffset == 0
- assert len(sessLog.logData) == 3
-
# Test Exporting
# ==============
- writeFile(sessFile, (
- "# Offset 1075\n"
- "# Start Time End Time Novel Notes Idle\n"
- "2021-01-31 19:00:00 2021-01-31 19:30:00 700 375 0\n"
- "2021-02-01 19:00:00 2021-02-01 19:30:00 700 375 10\n"
- "2021-02-01 20:00:00 2021-02-01 20:30:00 600 275 20\n"
- "2021-02-02 19:00:00 2021-02-02 19:30:00 750 425 30\n"
- "2021-02-02 20:00:00 2021-02-02 20:30:00 690 365 40\n"
- "2021-02-03 19:00:00 2021-02-03 19:30:00 680 355 50\n"
- "2021-02-04 19:00:00 2021-02-04 19:30:00 700 375 60\n"
- "2021-02-05 19:00:00 2021-02-05 19:30:00 500 175 70\n"
- "2021-02-06 19:00:00 2021-02-06 19:30:00 600 275 80\n"
- "2021-02-07 19:00:00 2021-02-07 19:30:00 600 275 90\n"
- ))
+ data = [
+ project.session.createInitial(1075),
+ project.session.createRecord("2021-01-31 19:00:00", "2021-01-31 19:30:00", 700, 375, 0),
+ project.session.createRecord("2021-02-01 19:00:00", "2021-02-01 19:30:00", 700, 375, 10),
+ project.session.createRecord("2021-02-01 20:00:00", "2021-02-01 20:30:00", 600, 275, 20),
+ project.session.createRecord("2021-02-02 19:00:00", "2021-02-02 19:30:00", 750, 425, 30),
+ project.session.createRecord("2021-02-02 20:00:00", "2021-02-02 20:30:00", 690, 365, 40),
+ project.session.createRecord("2021-02-03 19:00:00", "2021-02-03 19:30:00", 680, 355, 50),
+ project.session.createRecord("2021-02-04 19:00:00", "2021-02-04 19:30:00", 700, 375, 60),
+ project.session.createRecord("2021-02-05 19:00:00", "2021-02-05 19:30:00", 500, 175, 70),
+ project.session.createRecord("2021-02-06 19:00:00", "2021-02-06 19:30:00", 600, 275, 80),
+ project.session.createRecord("2021-02-07 19:00:00", "2021-02-07 19:30:00", 600, 275, 90),
+ ]
+ sessFile.write_text("".join(data), encoding="utf-8")
sessLog.populateGUI()
# Make the saving fail
From 61535b6ab60f8f1390e7d70ae40987496531c4e0 Mon Sep 17 00:00:00 2001
From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com>
Date: Tue, 13 Jun 2023 22:57:19 +0200
Subject: [PATCH 16/19] Fix minor mistakes in source files from last commits
---
novelwriter/constants.py | 1 -
novelwriter/core/index.py | 2 +-
novelwriter/core/project.py | 2 +-
novelwriter/core/sessions.py | 19 +++++++++----------
novelwriter/gui/outline.py | 2 +-
5 files changed, 12 insertions(+), 14 deletions(-)
diff --git a/novelwriter/constants.py b/novelwriter/constants.py
index 00e99713..2875a72b 100644
--- a/novelwriter/constants.py
+++ b/novelwriter/constants.py
@@ -89,7 +89,6 @@ class nwFiles:
BUILDS_FILE = "builds.json"
INDEX_FILE = "index.json"
OPTS_FILE = "options.json"
- PROJ_DICT = "wordlist.txt"
DICT_FILE = "userdict.json"
SESS_FILE = "sessions.jsonl"
diff --git a/novelwriter/core/index.py b/novelwriter/core/index.py
index 08abc873..d16d4015 100644
--- a/novelwriter/core/index.py
+++ b/novelwriter/core/index.py
@@ -517,7 +517,7 @@ class NWIndex:
"""Count the number of words in the novel project."""
wCount = 0
for _, _, hItem in self._itemIndex.iterNovelStructure(skipExcl=skipExcl):
- wCount += hItem.wordCount if isinstance(hItem, IndexHeading) else 0
+ wCount += hItem.wordCount
return wCount
def getNovelTitleCounts(self, skipExcl: bool = True) -> list[int]:
diff --git a/novelwriter/core/project.py b/novelwriter/core/project.py
index f4c9e454..2a78f9eb 100644
--- a/novelwriter/core/project.py
+++ b/novelwriter/core/project.py
@@ -34,7 +34,6 @@ from functools import partial
from PyQt5.QtCore import QCoreApplication, QObject, pyqtSignal
from novelwriter import CONFIG, __version__, __hexversion__
-from novelwriter.core.sessions import NWSessionLog
from novelwriter.enum import nwItemType, nwItemClass, nwItemLayout, nwAlert
from novelwriter.error import logException
from novelwriter.constants import trConst, nwLabels
@@ -43,6 +42,7 @@ from novelwriter.core.item import NWItem
from novelwriter.core.index import NWIndex
from novelwriter.core.options import OptionState
from novelwriter.core.storage import NWStorage
+from novelwriter.core.sessions import NWSessionLog
from novelwriter.core.projectxml import ProjectXMLReader, ProjectXMLWriter, XMLReadState
from novelwriter.core.projectdata import NWProjectData
from novelwriter.common import (
diff --git a/novelwriter/core/sessions.py b/novelwriter/core/sessions.py
index 0f3dfcf0..3ed0e897 100644
--- a/novelwriter/core/sessions.py
+++ b/novelwriter/core/sessions.py
@@ -34,7 +34,7 @@ from novelwriter.error import logException
from novelwriter.common import formatTimeStamp
from novelwriter.constants import nwFiles
-if TYPE_CHECKING:
+if TYPE_CHECKING: # pragma: no cover
from novelwriter.core.project import NWProject
logger = logging.getLogger(__name__)
@@ -112,15 +112,14 @@ class NWSessionLog:
def iterRecords(self) -> Iterator[dict]:
"""Iterate through all records in the log."""
sessFile = self._project.storage.getMetaFile(nwFiles.SESS_FILE)
- if not isinstance(sessFile, Path):
- return
- try:
- with open(sessFile, mode="r", encoding="utf-8") as fObj:
- for line in fObj:
- yield json.loads(line)
- except Exception:
- logger.error("Failed to process session stats file")
- logException()
+ if isinstance(sessFile, Path):
+ try:
+ with open(sessFile, mode="r", encoding="utf-8") as fObj:
+ for line in fObj:
+ yield json.loads(line)
+ except Exception:
+ logger.error("Failed to process session stats file")
+ logException()
return
def createInitial(self, total: int) -> str:
diff --git a/novelwriter/gui/outline.py b/novelwriter/gui/outline.py
index fd40d8ff..806f4d6b 100644
--- a/novelwriter/gui/outline.py
+++ b/novelwriter/gui/outline.py
@@ -45,9 +45,9 @@ from novelwriter import CONFIG
from novelwriter.enum import (
nwDocMode, nwItemClass, nwItemLayout, nwItemType, nwOutline
)
+from novelwriter.error import logException
from novelwriter.common import checkInt
from novelwriter.constants import nwHeaders, trConst, nwKeyWords, nwLabels
-from novelwriter.error import logException
from novelwriter.gui.components import NovelSelector
From 2a1324e6cc358148dc0c1f9893a430fdc89479e1 Mon Sep 17 00:00:00 2001
From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com>
Date: Tue, 13 Jun 2023 23:18:22 +0200
Subject: [PATCH 17/19] Make error dialog report a monospace font
---
novelwriter/error.py | 8 +++++++-
1 file changed, 7 insertions(+), 1 deletion(-)
diff --git a/novelwriter/error.py b/novelwriter/error.py
index efa6970f..79d30211 100644
--- a/novelwriter/error.py
+++ b/novelwriter/error.py
@@ -27,6 +27,7 @@ import sys
import random
import logging
+from PyQt5.QtGui import QFont, QFontDatabase
from PyQt5.QtCore import Qt
from PyQt5.QtWidgets import (
qApp, QDialog, QGridLayout, QStyle, QPlainTextEdit, QLabel,
@@ -74,7 +75,12 @@ class NWErrorMessage(QDialog):
self.msgHead.setOpenExternalLinks(True)
self.msgHead.setWordWrap(True)
+ font = QFont()
+ font.setPointSize(round(0.9*self.font().pointSize()))
+ font.setFamily(QFontDatabase.systemFont(QFontDatabase.FixedFont).family())
+
self.msgBody = QPlainTextEdit()
+ self.msgBody.setFont(font)
self.msgBody.setReadOnly(True)
self.btnBox = QDialogButtonBox(QDialogButtonBox.Close)
@@ -119,7 +125,7 @@ class NWErrorMessage(QDialog):
self.msgHead.setText(
"An unhandled error has been encountered.
"
"Please report this error by submitting an issue report on "
- "GitHub, providing a description and including the error "
+ "GitHub, providing a description, and including the error "
"message and traceback shown below.
"
f"URL: {nwConst.URL_REPORT}
"
)
From ca2964c037057d461cbf3166177526a375e84083 Mon Sep 17 00:00:00 2001
From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com>
Date: Thu, 15 Jun 2023 00:19:47 +0200
Subject: [PATCH 18/19] Add test coverage of session log class
---
novelwriter/core/sessions.py | 2 +-
tests/test_core/test_core_sessions.py | 114 ++++++++++++++++++++++++++
2 files changed, 115 insertions(+), 1 deletion(-)
create mode 100644 tests/test_core/test_core_sessions.py
diff --git a/novelwriter/core/sessions.py b/novelwriter/core/sessions.py
index 3ed0e897..7fe3fd3a 100644
--- a/novelwriter/core/sessions.py
+++ b/novelwriter/core/sessions.py
@@ -112,7 +112,7 @@ class NWSessionLog:
def iterRecords(self) -> Iterator[dict]:
"""Iterate through all records in the log."""
sessFile = self._project.storage.getMetaFile(nwFiles.SESS_FILE)
- if isinstance(sessFile, Path):
+ if isinstance(sessFile, Path) and sessFile.is_file():
try:
with open(sessFile, mode="r", encoding="utf-8") as fObj:
for line in fObj:
diff --git a/tests/test_core/test_core_sessions.py b/tests/test_core/test_core_sessions.py
new file mode 100644
index 00000000..d7ee58f1
--- /dev/null
+++ b/tests/test_core/test_core_sessions.py
@@ -0,0 +1,114 @@
+"""
+novelWriter – NWSessionLog Class Tester
+=======================================
+
+This file is a part of novelWriter
+Copyright 2018–2023, Veronica Berglyd Olsen
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU General Public License as published by
+the Free Software Foundation, either version 3 of the License, or
+(at your option) any later version.
+
+This program is distributed in the hope that it will be useful, but
+WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+General Public License for more details.
+
+You should have received a copy of the GNU General Public License
+along with this program. If not, see .
+"""
+
+import pytest
+
+from time import sleep
+from pathlib import Path
+
+from tools import buildTestProject
+from mocked import causeOSError
+
+from novelwriter.constants import nwFiles
+from novelwriter.core.project import NWProject
+from novelwriter.core.sessions import NWSessionLog
+
+
+@pytest.mark.core
+def testCoreSessions_Main(monkeypatch, mockGUI, fncPath):
+ """Test log file handling of the NWSessionLog class."""
+ project = NWProject(mockGUI)
+ buildTestProject(project, fncPath)
+
+ logFile = project.storage.getMetaFile(nwFiles.SESS_FILE)
+ assert isinstance(logFile, Path)
+
+ # Set some moch word counts
+ project.data.setInitCounts(50, 60)
+ project.data.setCurrCounts(160, 150)
+
+ # The project init should already have created the session
+ sessLog = project.session
+ assert isinstance(sessLog, NWSessionLog)
+ assert sessLog.start > 0.0
+
+ # Starting the session again should reset the timer
+ currTime = sessLog.start
+ sleep(0.015) # Make sure we don't hit clock resolution issues on Windows
+ sessLog.startSession()
+ assert sessLog.start > currTime
+
+ # There should not be a logfile
+ assert not logFile.exists()
+ assert len(list(sessLog.iterRecords())) == 0
+
+ # Create the initial and first records
+ assert sessLog.appendSession(0.8) is True
+ assert logFile.exists() # Logfile now exists
+ records = list(sessLog.iterRecords())
+ assert len(records) == 2
+ assert records[0]["type"] == "initial"
+ assert records[0]["offset"] == 110 # Sum of initial word counts
+ assert records[1]["type"] == "record"
+ assert records[1]["novel"] == 160
+ assert records[1]["notes"] == 150
+ 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)
+ 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)
+ sessLog._start -= 350.0 # Backdate the session start to allow logging
+ assert sessLog.appendSession(1.6) is True
+ records = list(sessLog.iterRecords())
+ assert len(records) == 3
+ assert records[2]["novel"] == 270
+ assert records[2]["notes"] == 240
+ assert records[2]["idle"] == 2 # Should be rounded to full seconds
+
+ # Make file path unresolvable, and try appending another record
+ with monkeypatch.context() as mp:
+ mp.setattr("novelwriter.core.storage.NWStorage.getMetaFile", lambda *a: None)
+ assert sessLog.appendSession(1.6) is False
+ assert len(list(sessLog.iterRecords())) == 3
+
+ # Make the file open fail, and check that it's handled
+ with monkeypatch.context() as mp:
+ mp.setattr("builtins.open", causeOSError)
+ assert sessLog.appendSession(1.6) is False
+ assert len(list(sessLog.iterRecords())) == 3
+
+ # Make the file load fail, and check that it's handled
+ with monkeypatch.context() as mp:
+ mp.setattr("builtins.open", causeOSError)
+ assert len(list(sessLog.iterRecords())) == 0
+
+ # Make file path unresolvable
+ with monkeypatch.context() as mp:
+ mp.setattr("novelwriter.core.storage.NWStorage.getMetaFile", lambda *a: None)
+ assert len(list(sessLog.iterRecords())) == 0
+
+# END Test testCoreSessions_Main
From 028abc0ce9e4bd3c80342deaf2f56d26c338f078 Mon Sep 17 00:00:00 2001
From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com>
Date: Thu, 15 Jun 2023 23:05:21 +0200
Subject: [PATCH 19/19] Update storage class and test coverage of it
---
novelwriter/core/storage.py | 143 +++++++++++------
novelwriter/error.py | 2 +-
novelwriter/gui/doceditor.py | 4 +-
tests/test_core/test_core_storage.py | 223 ++++++++++++++++++++++-----
4 files changed, 280 insertions(+), 92 deletions(-)
diff --git a/novelwriter/core/storage.py b/novelwriter/core/storage.py
index c56195bc..2b96c21b 100644
--- a/novelwriter/core/storage.py
+++ b/novelwriter/core/storage.py
@@ -23,6 +23,7 @@ along with this program. If not, see .
"""
from __future__ import annotations
+import json
import logging
from time import time
@@ -88,13 +89,11 @@ class NWStorage:
"""Return the path used for project content. The folder must
already exist, otherwise this property is None.
"""
- if self._runtimePath is not None:
+ if isinstance(self._runtimePath, Path):
contentPath = self._runtimePath / "content"
if contentPath.is_dir():
return contentPath
- else:
- logger.error("Path not found: %s", contentPath)
- return None
+ logger.error("Content path cannot be resolved")
return None
##
@@ -143,7 +142,6 @@ class NWStorage:
if self._openMode == self.MODE_INPLACE:
# Nothing to do, so we just return
return True
-
return True
def closeSession(self):
@@ -158,28 +156,26 @@ class NWStorage:
def getXmlReader(self) -> ProjectXMLReader | None:
"""Return a properly configured ProjectXMLReader instance."""
- if self._runtimePath is None:
- return None
- projFile = self._runtimePath / nwFiles.PROJ_FILE
- xmlReader = ProjectXMLReader(projFile)
- return xmlReader
+ if isinstance(self._runtimePath, Path):
+ projFile = self._runtimePath / nwFiles.PROJ_FILE
+ return ProjectXMLReader(projFile)
+ return None
def getXmlWriter(self) -> ProjectXMLWriter | None:
"""Return a properly configured ProjectXMLWriter instance."""
- if self._runtimePath is None:
- return None
- xmlWriter = ProjectXMLWriter(self._runtimePath)
- return xmlWriter
+ if isinstance(self._runtimePath, Path):
+ return ProjectXMLWriter(self._runtimePath)
+ return None
def getDocument(self, tHandle: str | None) -> NWDocument:
"""Return a document wrapper object."""
- if self._runtimePath is not None:
+ if isinstance(self._runtimePath, Path):
return NWDocument(self._project, tHandle)
return NWDocument(self._project, None)
def getMetaFile(self, fileName: str) -> Path | None:
"""Return the path to a file in the project meta folder."""
- if self._runtimePath is not None:
+ if isinstance(self._runtimePath, Path):
return self._runtimePath / "meta" / fileName
return None
@@ -320,13 +316,15 @@ class NWStorage:
# need for the remaning checks.
return True
+ legacy = _LegacyStorage(self._project)
+
# Check for legacy data folders
for child in path.iterdir():
if child.is_dir() and child.name.startswith("data_"):
- self._legacyDataFolder(path, child)
+ legacy.legacyDataFolder(path, child)
# Check for no longer used files, and delete them
- self._deprecatedFiles(path)
+ legacy.deprecatedFiles(path)
return True
@@ -334,7 +332,21 @@ class NWStorage:
# Legacy Project Data Handlers
##
- def _legacyDataFolder(self, path: Path, child: Path):
+# END Class NWStorage
+
+
+class _LegacyStorage:
+ """Core: Legacy Storage Converter Utils
+
+ A class with various functions to convert old file formats and
+ file/folder layout to the current project format.
+ """
+
+ def __init__(self, project: NWProject):
+ self._project = project
+ return
+
+ def legacyDataFolder(self, path: Path, child: Path):
"""Handle the content of a legacy data folder from a version 1.0
project.
"""
@@ -373,15 +385,20 @@ class NWStorage:
return
- def _deprecatedFiles(self, path: Path):
+ def deprecatedFiles(self, path: Path):
"""Handle files that are no longer used by novelWriter."""
- sessLog = path / "meta" / "sessionStats.log"
- if sessLog.is_file():
- self._convertOldLogFile(sessLog, path / "meta" / nwFiles.SESS_FILE)
-
- wordList = path / "meta" / "wordlist.txt"
- if wordList.is_file():
- self._convertOldWordList(wordList)
+ self._convertOldWordList( # Changed in 2.1 Beta 1
+ path / "meta" / "wordlist.txt",
+ path / "meta" / nwFiles.DICT_FILE
+ )
+ self._convertOldLogFile( # Changed in 2.1 Beta 1
+ path / "meta" / "sessionStats.log",
+ path / "meta" / nwFiles.SESS_FILE
+ )
+ self._convertOldOptionsFile( # Changed in 2.1 Beta 1
+ path / "meta" / "guiOptions.json",
+ path / "meta" / nwFiles.OPTS_FILE
+ )
remove = [
path / "meta" / "tagsIndex.json", # Renamed in 2.1 Beta 1
@@ -406,55 +423,51 @@ class NWStorage:
except Exception as exc:
logger.warning("Failed to delete: %s", item, exc_info=exc)
- # Renamed in 2.1 Beta 1, but this file we want to keep
- oldOpt = path / "meta" / "guiOptions.json"
- newOpt = path / "meta" / nwFiles.OPTS_FILE
- if oldOpt.is_file():
- try:
- oldOpt.rename(newOpt)
- logger.info("Renamed: %s > %s", oldOpt, newOpt)
- except Exception as exc:
- logger.warning("Failed to rename: %s", oldOpt, exc_info=exc)
-
return
- def _convertOldWordList(self, wordList: Path) -> bool:
+ ##
+ # Internal Functions
+ ##
+
+ def _convertOldWordList(self, wordList: Path, wordJson: Path):
"""Convert the old word list plain text file to new format."""
- if not wordList.exists():
- # Nothing to convert
- return True
+ if wordJson.exists() or not wordList.exists():
+ # If the new file already exists, we won't overwrite it
+ return
userDict = UserDictionary(self._project)
try:
+ logger.info("Converting: %s", wordList)
with open(wordList, mode="r", encoding="utf-8") as fObj:
for line in fObj:
word = line.strip()
if word:
userDict.add(word)
- # Dave dictionary and clean up old file
+ # Save dictionary and clean up old file
userDict.save()
+ assert wordJson.exists()
wordList.unlink()
except Exception:
logger.error("Failed to convert old word list file")
logException()
- return False
- return True
+ return
- def _convertOldLogFile(self, sessLog: Path, sessJson: Path) -> bool:
+ def _convertOldLogFile(self, sessLog: Path, sessJson: Path):
"""Convert the old text log file format to the new JSON Lines
format.
"""
if sessJson.exists() or not sessLog.exists():
# If the new file already exists, we won't overwrite it
- return True
+ return
try:
data = []
offset = 0
session = self._project.session
+ logger.info("Converting: %s", sessLog)
with open(sessLog, mode="r", encoding="utf-8") as fObj:
for record in fObj:
bits = record.split()
@@ -480,8 +493,40 @@ class NWStorage:
except Exception:
logger.error("Failed to convert old stats file")
logException()
- return False
- return True
+ return
-# END Class NWStorage
+ def _convertOldOptionsFile(self, optsOld: Path, optsNew: Path):
+ """Convert the old options state file format to the format."""
+ if optsNew.exists() or not optsOld.exists():
+ # If the new file already exists, we won't overwrite it
+ return
+
+ try:
+ data = {}
+ logger.info("Converting: %s", optsOld)
+ with open(optsOld, mode="r", encoding="utf-8") as fObj:
+ data = json.load(fObj)
+
+ # Convert Outline Values
+ state = {}
+ outline = data.get("GuiOutline", {})
+ hidden = outline.get("columnHidden", {})
+ width = outline.get("columnWidth", {})
+ for key in outline.get("headerOrder", []):
+ state[key] = [hidden.get(key, False), width.get(key, 100)]
+ data["columnState"] = state
+
+ with open(optsNew, mode="w", encoding="utf-8") as fObj:
+ json.dump({"novelWriter.guiOptions": data}, fObj, indent=2)
+
+ # If we're here, we remove the old file
+ optsOld.unlink()
+
+ except Exception:
+ logger.error("Failed to convert old options file")
+ logException()
+
+ return
+
+# END Class _LegacyStorage
diff --git a/novelwriter/error.py b/novelwriter/error.py
index 79d30211..73c08290 100644
--- a/novelwriter/error.py
+++ b/novelwriter/error.py
@@ -95,7 +95,7 @@ class NWErrorMessage(QDialog):
self.mainBox.setSpacing(16)
# Pick a random window title from a set of error messages by
- # Hex, the computer, from Discworld
+ # Hex the computer, Unseen University, Ankh-Morpork, Discworld
self.setWindowTitle([
"+++ Out of Cheese Error +++",
"+++ Divide by Cucumber Error +++",
diff --git a/novelwriter/gui/doceditor.py b/novelwriter/gui/doceditor.py
index 1074e549..37ef9fe7 100644
--- a/novelwriter/gui/doceditor.py
+++ b/novelwriter/gui/doceditor.py
@@ -611,9 +611,9 @@ class GuiDocEditor(QTextEdit):
def getText(self):
"""Get the text content of the current document. This method uses
- QTextDocument->toRawText instead of toPlainText(). The former preserves
+ QTextDocument->toRawText instead of toPlainText. The former preserves
non-breaking spaces, the latter does not. We still want to get rid of
- page and line separators though.
+ paragraph and line separators though.
See: https://doc.qt.io/qt-5/qtextdocument.html#toPlainText
"""
theText = self.document().toRawText()
diff --git a/tests/test_core/test_core_storage.py b/tests/test_core/test_core_storage.py
index c8967d63..50db40c8 100644
--- a/tests/test_core/test_core_storage.py
+++ b/tests/test_core/test_core_storage.py
@@ -19,22 +19,24 @@ You should have received a copy of the GNU General Public License
along with this program. If not, see .
"""
-from zipfile import ZipFile
+import json
import pytest
+from pathlib import Path
+from zipfile import ZipFile
+
from tools import C, buildTestProject, writeFile
from mocked import causeOSError
from novelwriter import CONFIG
from novelwriter.constants import nwFiles
from novelwriter.core.project import NWProject
-from novelwriter.core.storage import NWStorage
+from novelwriter.core.storage import NWStorage, _LegacyStorage
from novelwriter.core.projectxml import ProjectXMLReader, ProjectXMLWriter
class MockProject:
"""Test class for projects."""
-
pass
@@ -112,7 +114,7 @@ def testCoreStorage_LockFile(monkeypatch, fncPath):
"""Test the project lock file."""
monkeypatch.setattr("novelwriter.core.storage.time", lambda: 1000.0)
- storage = NWStorage(MockProject())
+ storage = NWStorage(MockProject()) # type: ignore
assert storage.isOpen() is False
# Project not open, so cannot read/write lock file
@@ -169,10 +171,46 @@ def testCoreStorage_LockFile(monkeypatch, fncPath):
# END Test testCoreStorage_LockFile
+@pytest.mark.core
+def testCoreStorage_ZipIt(monkeypatch, mockGUI, fncPath, tstPaths, mockRnd):
+ """Test making a zip archive of a project."""
+ zipFile = tstPaths.tmpDir / "project.zip"
+
+ theProject = NWProject(mockGUI)
+ storage = theProject.storage
+ assert storage.zipIt(zipFile) is False
+
+ # Make a project
+ mockRnd.reset()
+ buildTestProject(theProject, fncPath)
+
+ # Fail to create archive
+ with monkeypatch.context() as mp:
+ mp.setattr("novelwriter.core.storage.ZipFile.write", causeOSError)
+ assert storage.zipIt(zipFile) is False
+
+ # Create archive
+ assert storage.zipIt(zipFile) is True
+
+ # Check content
+ with ZipFile(zipFile, mode="r") as archive:
+ names = archive.namelist()
+ assert nwFiles.PROJ_FILE in names
+ assert f"meta/{nwFiles.OPTS_FILE}" in names
+ assert f"meta/{nwFiles.INDEX_FILE}" in names
+ assert f"content/{C.hTitlePage}.nwd" in names
+ assert f"content/{C.hChapterDoc}.nwd" in names
+ assert f"content/{C.hSceneDoc}.nwd" in names
+
+ theProject.closeProject()
+
+# END Test testCoreStorage_ZipIt
+
+
@pytest.mark.core
def testCoreStorage_PrepareStorage(monkeypatch, fncPath):
"""Test the project path preparation functions."""
- storage = NWStorage(MockProject())
+ storage = NWStorage(MockProject()) # type: ignore
assert storage.isOpen() is False
# No path set
@@ -208,9 +246,18 @@ def testCoreStorage_PrepareStorage(monkeypatch, fncPath):
storage._runtimePath = fncPath
assert storage._prepareStorage(checkLegacy=False, newProject=True) is False
- # Legacy Data Folder
- # ==================
+# END Test testCoreStorage_PrepareStorage
+
+
+@pytest.mark.core
+def testCoreStorage_LegacyDataFolder(monkeypatch, fncPath):
+ """Test project file format 1.0 folder structure conversion."""
+ project = MockProject()
+ storage = NWStorage(project) # type: ignore
+ assert storage.isOpen() is False
storage._runtimePath = fncPath
+ assert storage._prepareStorage() is True
+ legacy = _LegacyStorage(project) # type: ignore
data = []
files = []
@@ -235,7 +282,7 @@ def testCoreStorage_PrepareStorage(monkeypatch, fncPath):
# Process folders
for i in range(9):
- storage._legacyDataFolder(fncPath, data[i])
+ legacy.legacyDataFolder(fncPath, data[i])
# Files form 0 to 8 should now be in content
for c in "012345678":
@@ -250,14 +297,14 @@ def testCoreStorage_PrepareStorage(monkeypatch, fncPath):
assert data[8].exists()
# So does folder X, which is invalid
- storage._legacyDataFolder(fncPath, data[16])
+ legacy.legacyDataFolder(fncPath, data[16])
assert data[16].exists()
# Fail cleanup of folder 9
with monkeypatch.context() as mp:
mp.setattr("pathlib.Path.rename", causeOSError)
mp.setattr("pathlib.Path.unlink", causeOSError)
- storage._legacyDataFolder(fncPath, data[9])
+ legacy.legacyDataFolder(fncPath, data[9])
assert data[9].exists()
assert not (fncPath / "content" / "9000000000009.nwd").exists()
@@ -266,10 +313,24 @@ def testCoreStorage_PrepareStorage(monkeypatch, fncPath):
for c in "0123456789abcdef":
assert (fncPath / "content" / f"{c}00000000000{c}.nwd").exists()
- # Deprecated Files
- # ================
+# END Test testCoreStorage_LegacyDataFolder
+
+
+@pytest.mark.core
+def testCoreStorage_DeprecatedFiles(monkeypatch, fncPath):
+ """Test cleanup of deprecated files."""
+ project = MockProject()
+ storage = NWStorage(project) # type: ignore
+ assert storage.isOpen() is False
+ storage._runtimePath = fncPath
+ assert storage._prepareStorage() is True
+ legacy = _LegacyStorage(project) # type: ignore
+
+ # Files/Folders to be Deleted or Renamed
+ # ======================================
remove = [
+ fncPath / "meta" / "tagsIndex.json",
fncPath / "meta" / "mainOptions.json",
fncPath / "meta" / "exportOptions.json",
fncPath / "meta" / "outlineOptions.json",
@@ -286,48 +347,130 @@ def testCoreStorage_PrepareStorage(monkeypatch, fncPath):
with monkeypatch.context() as mp:
mp.setattr("pathlib.Path.unlink", causeOSError)
- storage._deprecatedFiles(fncPath)
+ legacy.deprecatedFiles(fncPath)
for depFile in remove:
assert depFile.exists()
- storage._deprecatedFiles(fncPath)
+ legacy.deprecatedFiles(fncPath)
for depFile in remove:
assert not depFile.exists()
-# END Test testCoreStorage_PrepareStorage
+# END Test testCoreStorage_DeprecatedFiles
@pytest.mark.core
-def testCoreStorage_ZipIt(monkeypatch, mockGUI, fncPath, tstPaths, mockRnd):
- """Test making a zip archive of a project."""
- zipFile = tstPaths.tmpDir / "project.zip"
+def testCoreStorage_OldFormatConvert(monkeypatch, mockGUI, fncPath):
+ """Test cleanup of deprecated files that needs to be converted."""
+ project = NWProject(mockGUI)
+ buildTestProject(project, fncPath)
+ legacy = _LegacyStorage(project)
- theProject = NWProject(mockGUI)
- storage = theProject.storage
- assert storage.zipIt(zipFile) is False
+ # The build project functions saves the project, so we must delete
+ # the old gui options file
+ (fncPath / "meta" / nwFiles.OPTS_FILE).unlink()
- # Make a project
- mockRnd.reset()
- buildTestProject(theProject, fncPath)
+ # Word List
+ wordListOld: Path = fncPath / "meta" / "wordlist.txt"
+ wordListNew: Path = fncPath / "meta" / nwFiles.DICT_FILE
- # Fail to create archive
+ wordListOld.write_text((
+ "word_a\n"
+ "word_b\n"
+ "word_c\n"
+ ), encoding="utf-8")
+
+ assert wordListOld.exists() is True
+ assert wordListNew.exists() is False
+
+ # Log File
+ sessLogOld: Path = fncPath / "meta" / "sessionStats.log"
+ sessLogNew: Path = fncPath / "meta" / nwFiles.SESS_FILE
+
+ 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"
+ ), encoding="utf-8")
+
+ assert sessLogOld.exists() is True
+ assert sessLogNew.exists() is False
+
+ # Options File
+ optionsOld: Path = fncPath / "meta" / "guiOptions.json"
+ optionsNew: Path = fncPath / "meta" / nwFiles.OPTS_FILE
+
+ optionsOld.write_text(json.dumps({
+ "GuiProjectSettings": {
+ "winWidth": 570,
+ "winHeight": 375,
+ },
+ "GuiOutline": {
+ "headerOrder": ["TITLE", "LEVEL", "LABEL", "LINE"],
+ "columnWidth": {"TITLE": 325, "LEVEL": 40, "LABEL": 267, "LINE": 40},
+ "columnHidden": {"TITLE": False, "LEVEL": True, "LABEL": False, "LINE": True},
+ },
+ }, indent=2), encoding="utf-8")
+
+ assert optionsOld.exists() is True
+ assert optionsNew.exists() is False
+
+ # Check Failure
with monkeypatch.context() as mp:
- mp.setattr("novelwriter.core.storage.ZipFile.write", causeOSError)
- assert storage.zipIt(zipFile) is False
+ mp.setattr("builtins.open", causeOSError)
+ legacy.deprecatedFiles(fncPath)
+ assert wordListOld.exists() is True
+ assert wordListNew.exists() is False
+ assert sessLogOld.exists() is True
+ assert sessLogNew.exists() is False
+ assert optionsOld.exists() is True
+ assert optionsNew.exists() is False
- # Create archive
- assert storage.zipIt(zipFile) is True
+ # Check Success
+ legacy.deprecatedFiles(fncPath)
+ assert wordListOld.exists() is False
+ assert wordListNew.exists() is True
+ assert sessLogOld.exists() is False
+ assert sessLogNew.exists() is True
+ assert optionsOld.exists() is False
+ assert optionsNew.exists() is True
- # Check content
- with ZipFile(zipFile, mode="r") as archive:
- names = archive.namelist()
- assert nwFiles.PROJ_FILE in names
- assert f"meta/{nwFiles.OPTS_FILE}" in names
- assert f"meta/{nwFiles.INDEX_FILE}" in names
- assert f"content/{C.hTitlePage}.nwd" in names
- assert f"content/{C.hChapterDoc}.nwd" in names
- assert f"content/{C.hSceneDoc}.nwd" in names
+ # Check Word List
+ data = json.loads(wordListNew.read_text(encoding="utf-8"))
+ assert "word_a" in data["novelWriter.userDict"]
+ assert "word_b" in data["novelWriter.userDict"]
+ assert "word_c" in data["novelWriter.userDict"]
- theProject.closeProject()
+ # Check Session Log
+ data = list(project.session.iterRecords())
+ assert data[0] == {"type": "initial", "offset": 150}
+ assert data[1] == {
+ "type": "record",
+ "start": "2021-02-02 02:02:02",
+ "end": "2021-02-02 03:03:03",
+ "novel": 200,
+ "notes": 200,
+ "idle": 10,
+ }
+ assert data[2] == {
+ "type": "record",
+ "start": "2021-03-03 03:03:03",
+ "end": "2021-03-03 04:04:04",
+ "novel": 300,
+ "notes": 300,
+ "idle": 20,
+ }
-# END Test testCoreStorage_ZipIt
+ # Check Options File
+ data = json.loads(optionsNew.read_text(encoding="utf-8"))
+ assert data["novelWriter.guiOptions"]["GuiProjectSettings"] == {
+ "winWidth": 570, "winHeight": 375
+ }
+ assert data["novelWriter.guiOptions"]["columnState"] == {
+ "TITLE": [False, 325],
+ "LEVEL": [True, 40],
+ "LABEL": [False, 267],
+ "LINE": [True, 40]
+ }
+
+# END Test testCoreStorage_OldFormatConvert