Rename and restructure project meta files (#1464)

This commit is contained in:
Veronica Berglyd Olsen
2023-06-15 23:13:14 +02:00
committed by GitHub
39 changed files with 1413 additions and 1006 deletions
+1 -2
View File
@@ -104,8 +104,7 @@ def checkBool(value: Any, default: bool) -> bool:
def checkHandle(value, default, allowNone=False): def checkHandle(value, default, allowNone=False):
"""Check if a value is a handle. """Check if a value is a handle."""
"""
if allowNone and (value is None or value == "None"): if allowNone and (value is None or value == "None"):
return None return None
if isHandle(value): if isHandle(value):
+4 -4
View File
@@ -87,10 +87,10 @@ class nwFiles:
# Project Meta Files # Project Meta Files
BUILDS_FILE = "builds.json" BUILDS_FILE = "builds.json"
INDEX_FILE = "tagsIndex.json" INDEX_FILE = "index.json"
OPTS_FILE = "guiOptions.json" OPTS_FILE = "options.json"
PROJ_DICT = "wordlist.txt" DICT_FILE = "userdict.json"
SESS_STATS = "sessionStats.log" SESS_FILE = "sessions.jsonl"
# END Class nwFiles # END Class nwFiles
+1 -3
View File
@@ -1,7 +1,6 @@
""" """
novelWriter Project Document Tools novelWriter Project Document Tools
==================================== ====================================
A collection of tools to create and manipulate documents
File History: File History:
Created: 2022-10-02 [2.0rc1] DocMerger Created: 2022-10-02 [2.0rc1] DocMerger
@@ -28,7 +27,6 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
import shutil import shutil
import logging import logging
from time import time
from functools import partial from functools import partial
from PyQt5.QtCore import QCoreApplication from PyQt5.QtCore import QCoreApplication
@@ -319,7 +317,7 @@ class ProjectBuilder:
project.data.setTitle(projTitle) project.data.setTitle(projTitle)
project.data.setAuthor(projAuthor) project.data.setAuthor(projAuthor)
project.setDefaultStatusImport() project.setDefaultStatusImport()
project._projOpened = int(time()) project.session.startSession()
# Add Root Folders # Add Root Folders
hNovelRoot = project.newRoot(nwItemClass.NOVEL) hNovelRoot = project.newRoot(nwItemClass.NOVEL)
+245 -268
View File
File diff suppressed because it is too large Load Diff
+30 -28
View File
@@ -32,7 +32,7 @@ from typing import TYPE_CHECKING, Any
from pathlib import Path from pathlib import Path
from novelwriter.error import logException from novelwriter.error import logException
from novelwriter.common import checkBool, checkFloat, checkInt, checkString from novelwriter.common import checkBool, checkFloat, checkInt, checkString, jsonEncode
from novelwriter.constants import nwFiles from novelwriter.constants import nwFiles
if TYPE_CHECKING: # pragma: no cover if TYPE_CHECKING: # pragma: no cover
@@ -47,7 +47,7 @@ VALID_MAP = {
"hideZeros", "hideNegative", "groupByDay", "showIdleTime", "histMax", "hideZeros", "hideNegative", "groupByDay", "showIdleTime", "histMax",
}, },
"GuiDocSplit": {"spLevel", "intoFolder", "docHierarchy"}, "GuiDocSplit": {"spLevel", "intoFolder", "docHierarchy"},
"GuiOutline": {"headerOrder", "columnWidth", "columnHidden"}, "GuiOutline": {"columnState"},
"GuiProjectSettings": { "GuiProjectSettings": {
"winWidth", "winHeight", "replaceColW", "statusColW", "importColW", "winWidth", "winHeight", "replaceColW", "statusColW", "importColW",
}, },
@@ -79,7 +79,7 @@ class OptionState:
def __init__(self, project: NWProject): def __init__(self, project: NWProject):
self._project = project self._project = project
self._theState = {} self._state = {}
return return
## ##
@@ -93,24 +93,25 @@ class OptionState:
if not isinstance(stateFile, Path): if not isinstance(stateFile, Path):
return False return False
theState = {} data = {}
if stateFile.exists(): if stateFile.exists():
logger.debug("Loading GUI options file") logger.debug("Loading GUI options file")
try: try:
with open(stateFile, mode="r", encoding="utf-8") as inFile: with open(stateFile, mode="r", encoding="utf-8") as inFile:
theState = json.load(inFile) data = json.load(inFile)
except Exception: except Exception:
logger.error("Failed to load GUI options file") logger.error("Failed to load GUI options file")
logException() logException()
return False return False
# Filter out unused variables # Filter out unused variables
for aGroup in theState: state = data.get("novelWriter.guiOptions", {})
for aGroup in state:
if aGroup in VALID_MAP: if aGroup in VALID_MAP:
self._theState[aGroup] = {} self._state[aGroup] = {}
for anOpt in theState[aGroup]: for anOpt in state[aGroup]:
if anOpt in VALID_MAP[aGroup]: if anOpt in VALID_MAP[aGroup]:
self._theState[aGroup][anOpt] = theState[aGroup][anOpt] self._state[aGroup][anOpt] = state[aGroup][anOpt]
return True return True
@@ -122,8 +123,9 @@ class OptionState:
logger.debug("Saving GUI options file") logger.debug("Saving GUI options file")
try: try:
with open(stateFile, mode="w+", encoding="utf-8") as outFile: with open(stateFile, mode="w+", encoding="utf-8") as fObj:
json.dump(self._theState, outFile, indent=2) data = {"novelWriter.guiOptions": self._state}
fObj.write(jsonEncode(data, nmax=4))
except Exception: except Exception:
logger.error("Failed to save GUI options file") logger.error("Failed to save GUI options file")
logException() logException()
@@ -145,13 +147,13 @@ class OptionState:
logger.error("Unknown option name '%s'", name) logger.error("Unknown option name '%s'", name)
return False return False
if group not in self._theState: if group not in self._state:
self._theState[group] = {} self._state[group] = {}
if isinstance(value, Enum): if isinstance(value, Enum):
self._theState[group][name] = value.name self._state[group][name] = value.name
else: else:
self._theState[group][name] = value self._state[group][name] = value
return True return True
@@ -163,40 +165,40 @@ class OptionState:
"""Return an arbitrary type value, if it exists. Otherwise, """Return an arbitrary type value, if it exists. Otherwise,
return the default value. return the default value.
""" """
if group in self._theState: if group in self._state:
return self._theState[group].get(name, default) return self._state[group].get(name, default)
return default return default
def getString(self, group: str, name: str, default: str) -> str: def getString(self, group: str, name: str, default: str) -> str:
"""Return the value as a string, if it exists. Otherwise, return """Return the value as a string, if it exists. Otherwise, return
the default value. the default value.
""" """
if group in self._theState: if group in self._state:
return checkString(self._theState[group].get(name, default), default) return checkString(self._state[group].get(name, default), default)
return default return default
def getInt(self, group: str, name: str, default: int) -> int: def getInt(self, group: str, name: str, default: int) -> int:
"""Return the value as an int, if it exists. Otherwise, return """Return the value as an int, if it exists. Otherwise, return
the default value. the default value.
""" """
if group in self._theState: if group in self._state:
return checkInt(self._theState[group].get(name, default), default) return checkInt(self._state[group].get(name, default), default)
return default return default
def getFloat(self, group: str, name: str, default: float) -> float: def getFloat(self, group: str, name: str, default: float) -> float:
"""Return the value as a float, if it exists. Otherwise, return """Return the value as a float, if it exists. Otherwise, return
the default value. the default value.
""" """
if group in self._theState: if group in self._state:
return checkFloat(self._theState[group].get(name, default), default) return checkFloat(self._state[group].get(name, default), default)
return default return default
def getBool(self, group: str, name: str, default: bool) -> bool: def getBool(self, group: str, name: str, default: bool) -> bool:
"""Return the value as a bool, if it exists. Otherwise, return """Return the value as a bool, if it exists. Otherwise, return
the default value. the default value.
""" """
if group in self._theState: if group in self._state:
return checkBool(self._theState[group].get(name, default), default) return checkBool(self._state[group].get(name, default), default)
return default return default
def getEnum(self, group: str, name: str, lookup: type, default: Enum) -> Enum: def getEnum(self, group: str, name: str, lookup: type, default: Enum) -> Enum:
@@ -204,9 +206,9 @@ class OptionState:
default value. default value.
""" """
if issubclass(lookup, Enum): if issubclass(lookup, Enum):
if group in self._theState: if group in self._state:
if name in self._theState[group]: if name in self._state[group]:
value = self._theState[group][name] value = self._state[group][name]
if value in lookup.__members__: if value in lookup.__members__:
return lookup[value] return lookup[value]
return default return default
+17 -58
View File
@@ -22,6 +22,7 @@ General Public License for more details.
You should have received a copy of the GNU General Public License You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>. along with this program. If not, see <https://www.gnu.org/licenses/>.
""" """
from __future__ import annotations
import json import json
import logging import logging
@@ -35,12 +36,13 @@ from PyQt5.QtCore import QCoreApplication, QObject, pyqtSignal
from novelwriter import CONFIG, __version__, __hexversion__ from novelwriter import CONFIG, __version__, __hexversion__
from novelwriter.enum import nwItemType, nwItemClass, nwItemLayout, nwAlert from novelwriter.enum import nwItemType, nwItemClass, nwItemLayout, nwAlert
from novelwriter.error import logException from novelwriter.error import logException
from novelwriter.constants import trConst, nwFiles, nwLabels from novelwriter.constants import trConst, nwLabels
from novelwriter.core.tree import NWTree from novelwriter.core.tree import NWTree
from novelwriter.core.item import NWItem from novelwriter.core.item import NWItem
from novelwriter.core.index import NWIndex from novelwriter.core.index import NWIndex
from novelwriter.core.options import OptionState from novelwriter.core.options import OptionState
from novelwriter.core.storage import NWStorage from novelwriter.core.storage import NWStorage
from novelwriter.core.sessions import NWSessionLog
from novelwriter.core.projectxml import ProjectXMLReader, ProjectXMLWriter, XMLReadState from novelwriter.core.projectxml import ProjectXMLReader, ProjectXMLWriter, XMLReadState
from novelwriter.core.projectdata import NWProjectData from novelwriter.core.projectdata import NWProjectData
from novelwriter.common import ( from novelwriter.common import (
@@ -66,12 +68,12 @@ class NWProject(QObject):
self._data = NWProjectData(self) # The project settings self._data = NWProjectData(self) # The project settings
self._tree = NWTree(self) # The project tree self._tree = NWTree(self) # The project tree
self._index = NWIndex(self) # The projecty index self._index = NWIndex(self) # The projecty index
self._session = NWSessionLog(self) # The session record
# Data Cache # Data Cache
self._langData = {} # Localisation data self._langData = {} # Localisation data
# Project Status # Project Status
self._projOpened = 0 # The time stamp of when the project file was opened
self._projChanged = False # The project has unsaved changes self._projChanged = False # The project has unsaved changes
self._lockedBy = None # Data on which computer has the project open self._lockedBy = None # Data on which computer has the project open
self._projFiles = [] # A list of all files in the content folder on load self._projFiles = [] # A list of all files in the content folder on load
@@ -108,9 +110,13 @@ class NWProject(QObject):
def index(self): def index(self):
return self._index return self._index
@property
def session(self) -> NWSessionLog:
return self._session
@property @property
def projOpened(self): def projOpened(self):
return self._projOpened return self._session.start
@property @property
def projChanged(self): def projChanged(self):
@@ -228,7 +234,6 @@ class NWProject(QObject):
default values. default values.
""" """
# Project Status # Project Status
self._projOpened = 0
self._projChanged = False self._projChanged = False
# Project Tree # Project Tree
@@ -236,6 +241,7 @@ class NWProject(QObject):
self._tree.clear() self._tree.clear()
self._index.clearIndex() self._index.clearIndex()
self._data = NWProjectData(self) self._data = NWProjectData(self)
self._session = NWSessionLog(self)
# Project Settings # Project Settings
self._projFiles = [] self._projFiles = []
@@ -370,8 +376,7 @@ class NWProject(QObject):
self._index.rebuildIndex() self._index.rebuildIndex()
self.updateWordCounts() self.updateWordCounts()
self._projOpened = time() self._session.startSession()
self._storage.writeLockFile() self._storage.writeLockFile()
self.setProjectChanged(False) self.setProjectChanged(False)
self.mainGui.setStatus(self.tr("Opened Project: {0}").format(self._data.name)) self.mainGui.setStatus(self.tr("Opened Project: {0}").format(self._data.name))
@@ -407,7 +412,7 @@ class NWProject(QObject):
return False return False
saveTime = time() saveTime = time()
editTime = int(self._data.editTime + saveTime - self._projOpened) editTime = self._data.editTime + max(round(saveTime - self._session.start), 0)
content = self._tree.pack() content = self._tree.pack()
if not xmlWriter.write(self._data, content, saveTime, editTime): if not xmlWriter.write(self._data, content, saveTime, editTime):
self.mainGui.makeAlert(self.tr( self.mainGui.makeAlert(self.tr(
@@ -437,7 +442,7 @@ class NWProject(QObject):
logger.info("Closing project") logger.info("Closing project")
self._options.saveSettings() self._options.saveSettings()
self._tree.writeToCFile() self._tree.writeToCFile()
self._appendSessionStats(idleTime) self._session.appendSession(idleTime)
self._storage.clearLockFile() self._storage.clearLockFile()
self._storage.closeSession() self._storage.closeSession()
self.clearProject() self.clearProject()
@@ -560,18 +565,17 @@ class NWProject(QObject):
# Getters # Getters
## ##
def getLockStatus(self): def getLockStatus(self) -> list | None:
"""Return the project lock information for the project. """Return the project lock information for the project."""
"""
if isinstance(self._lockedBy, list) and len(self._lockedBy) == 4: if isinstance(self._lockedBy, list) and len(self._lockedBy) == 4:
return self._lockedBy return self._lockedBy
return None return None
def getCurrentEditTime(self): def getCurrentEditTime(self) -> int:
"""Get the total project edit time, including the time spent in """Get the total project edit time, including the time spent in
the current session. the current session.
""" """
return round(self._data.editTime + time() - self._projOpened) return self._data.editTime + round(time() - self._session.start)
def getProjectItems(self): def getProjectItems(self):
"""This function ensures that the item tree loaded is sent to """This function ensures that the item tree loaded is sent to
@@ -798,49 +802,4 @@ class NWProject(QObject):
return True return True
def _appendSessionStats(self, idleTime):
"""Append session statistics to the sessions log file.
"""
sessionFile = self._storage.getMetaFile(nwFiles.SESS_STATS)
if not isinstance(sessionFile, Path):
return False
nowTime = time()
iNovel, iNotes = self._data.initCounts
cNovel, cNotes = self._data.currCounts
iTotal = iNovel + iNotes
sessDiff = cNovel + cNotes - iTotal
sessTime = nowTime - self._projOpened
logger.info("The session lasted %d sec and added %d words", int(sessTime), sessDiff)
if sessTime < 300 and sessDiff == 0:
logger.info("Session too short, skipping log entry")
return False
try:
isFile = sessionFile.exists() # We must save the state before we open
with open(sessionFile, mode="a+", encoding="utf-8") as outFile:
if not isFile:
# It's a new file, so add a header
if iTotal > 0:
outFile.write("# Offset %d\n" % iTotal)
outFile.write("# %-17s %-19s %8s %8s %8s\n" % (
"Start Time", "End Time", "Novel", "Notes", "Idle"
))
outFile.write("%-19s %-19s %8d %8d %8d\n" % (
formatTimeStamp(self._projOpened),
formatTimeStamp(nowTime),
cNovel,
cNotes,
int(idleTime),
))
except Exception:
logger.error("Failed to write session stats file")
logException()
return False
return True
# END Class NWProject # END Class NWProject
+138
View File
@@ -0,0 +1,138 @@
"""
novelWriter Project Session Log Class
=======================================
File History:
Created: 2023-06-11 [2.1b1]
This file is a part of novelWriter
Copyright 20182023, 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 <https://www.gnu.org/licenses/>.
"""
from __future__ import annotations
import json
import logging
from time import time
from typing import TYPE_CHECKING, Iterator
from pathlib import Path
from novelwriter.error import logException
from novelwriter.common import formatTimeStamp
from novelwriter.constants import nwFiles
if TYPE_CHECKING: # pragma: no cover
from novelwriter.core.project import NWProject
logger = logging.getLogger(__name__)
class NWSessionLog:
"""Core: Session JSON Lines Log File
The class that wraps the session log file, which is in JSON Lines
format. That is, one JSON object per line.
"""
def __init__(self, project: NWProject):
self._project = project
self._start = 0.0
return
##
# Properties
##
@property
def start(self) -> float:
"""The session start time."""
return self._start
##
# Methods
##
def startSession(self):
"""Start the writng session."""
self._start = time()
return
def appendSession(self, idleTime: float) -> bool:
"""Append session statistics to the sessions log file."""
sessFile = self._project.storage.getMetaFile(nwFiles.SESS_FILE)
if not isinstance(sessFile, Path):
return False
now = time()
iNovel, iNotes = self._project.data.initCounts
cNovel, cNotes = self._project.data.currCounts
iTotal = iNovel + iNotes
wDiff = cNovel + cNotes - iTotal
sTime = now - self._start
logger.info("The session lasted %d sec and added %d words", int(sTime), wDiff)
if sTime < 300 and wDiff == 0:
logger.info("Session too short, skipping log entry")
return False
try:
if not sessFile.exists():
with open(sessFile, mode="w", encoding="utf-8") as fObj:
fObj.write(self.createInitial(iTotal))
with open(sessFile, mode="a+", encoding="utf-8") as fObj:
fObj.write(self.createRecord(
start=formatTimeStamp(self._start),
end=formatTimeStamp(now),
novel=cNovel,
notes=cNotes,
idle=round(idleTime)
))
except Exception:
logger.error("Failed to write to session stats file")
logException()
return False
return True
def iterRecords(self) -> Iterator[dict]:
"""Iterate through all records in the log."""
sessFile = self._project.storage.getMetaFile(nwFiles.SESS_FILE)
if isinstance(sessFile, Path) and sessFile.is_file():
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:
"""Low level function to create the initial log file record."""
data = json.dumps({"type": "initial", "offset": total})
return f"{data}\n"
def createRecord(self, start: str, end: str, novel: int, notes: int, idle: int) -> str:
"""Low level function to create a log record."""
data = json.dumps({
"type": "record", "start": start, "end": end,
"novel": novel, "notes": notes, "idle": idle,
})
return f"{data}\n"
# END Class NWSessionLog
+124 -101
View File
@@ -1,7 +1,6 @@
""" """
novelWriter Spell Check Classes novelWriter Spell Check Classes
================================= =================================
Wrapper classes for spell checking tools
File History: File History:
Created: 2019-06-11 [0.1.5] 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 You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>. along with this program. If not, see <https://www.gnu.org/licenses/>.
""" """
from __future__ import annotations
import json
import logging import logging
from collections import namedtuple from typing import TYPE_CHECKING, Iterator
from pathlib import Path from pathlib import Path
from novelwriter.error import logException 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__) logger = logging.getLogger(__name__)
class NWSpellEnchant: class NWSpellEnchant:
"""Core: Enchant Spell Checking Wrapper
def __init__(self): This is a rapper class for Enchant to keep the API consistent
between spell check tools.
self._theDict = None """
self._projDict = set()
self._projectDict = None
self._spellLanguage = None
self._theBroker = None
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") logger.debug("Enchant spell checking activated")
return return
## ##
@@ -52,43 +59,43 @@ class NWSpellEnchant:
## ##
@property @property
def spellLanguage(self): def spellLanguage(self) -> str | None:
return self._spellLanguage return self._language
## ##
# Setters # Setters
## ##
def setLanguage(self, theLang, projectDict=None): def setLanguage(self, language: str | None):
"""Load a dictionary for the language specified in the config. """Load a dictionary for the language specified in the config.
If that fails, we load a mock dictionary so that lookups don't If that fails, we load a mock dictionary so that lookups don't
crash. Note that enchant will allow loading an empty string as crash. Note that enchant will allow loading an empty string as
a tag, but this will fail later on. See issue #1096. a tag, but this will fail later on. See issue #1096.
""" """
self._theBroker = None self._dictObj = FakeEnchant()
self._theDict = None self._broker = None
self._spellLanguage = None self._language = None
try: try:
import enchant import enchant
if theLang and enchant.dict_exists(theLang): if language and enchant.dict_exists(language):
self._theBroker = enchant.Broker() self._broker = enchant.Broker()
self._theDict = self._theBroker.request_dict(theLang) self._dictObj = self._broker.request_dict(language)
self._spellLanguage = theLang self._language = language
logger.debug("Enchant spell checking for language '%s' loaded", theLang) logger.debug("Enchant spell checking for language '%s' loaded", language)
else: else:
logger.warning("Enchant found no dictionary for language '%s'", theLang) logger.warning("Enchant found no dictionary for language '%s'", language)
except Exception: 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: if self._dictObj is None:
self._theDict = FakeEnchant() self._dictObj = FakeEnchant()
else: else:
self._readProjectDictionary(projectDict) self._userDict.load()
for pWord in self._projDict: for pWord in self._userDict:
self._theDict.add_to_session(pWord) self._dictObj.add_to_session(pWord)
return return
@@ -96,47 +103,38 @@ class NWSpellEnchant:
# Methods # Methods
## ##
def checkWord(self, theWord): def checkWord(self, word: str) -> bool:
"""Wrapper function for pyenchant. """Wrapper function for pyenchant."""
"""
try: try:
return self._theDict.check(theWord) return bool(self._dictObj.check(word))
except Exception: except Exception:
return True return True
def suggestWords(self, theWord): def suggestWords(self, word: str) -> list[str]:
"""Wrapper function for pyenchant. """Wrapper function for pyenchant."""
"""
try: try:
return self._theDict.suggest(theWord) return self._dictObj.suggest(word)
except Exception: except Exception:
return [] return []
def addWord(self, newWord): def addWord(self, word: str) -> bool:
"""Add a word to the project dictionary. """Add a word to the project dictionary."""
""" word = word.strip()
if not word:
return False
try: try:
self._theDict.add_to_session(newWord) self._dictObj.add_to_session(word)
except Exception: except Exception:
return False return False
if self._projectDict is not None and newWord not in self._projDict: added = self._userDict.add(word)
newWord = newWord.strip() if added:
try: self._userDict.save()
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
return False return added
def listDictionaries(self): def listDictionaries(self) -> list[tuple[str, str]]:
"""Wrapper function for pyenchant. """Wrapper function for pyenchant."""
"""
retList = [] retList = []
try: try:
import enchant import enchant
@@ -147,73 +145,98 @@ class NWSpellEnchant:
return retList return retList
def describeDict(self): def describeDict(self) -> tuple[str, str]:
"""Return the tag and provider of the currently loaded """Return the tag and provider of the currently loaded
dictionary. dictionary.
""" """
try: try:
spTag = self._theDict.tag tag = self._dictObj.tag
spName = self._theDict.provider.name name = self._dictObj.provider.name # type: ignore
except Exception: except Exception:
logger.error("Failed to extract information about the dictionary") logger.error("Failed to extract information about the dictionary")
logException() logException()
spTag = "" tag = ""
spName = "" name = ""
return spTag, spName return tag, name
##
# 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
# END Class NWSpellEnchant # END Class NWSpellEnchant
class FakeEnchant: class FakeEnchant:
"""Fallback for when Enchant is selected, but not installed. """Fallback for when Enchant is selected, but not installed."""
"""
def __init__(self): def __init__(self):
class FakeProvider:
name = ""
self.tag = "" self.tag = ""
self.provider = namedtuple("provider", "name") self.provider = FakeProvider()
self.provider.name = ""
return return
def check(self, theWord): def check(self, word: str) -> bool:
return True return True
def suggest(self, theWord): def suggest(self, word) -> list[str]:
return [] return []
def add_to_session(self, theWord): def add_to_session(self, word: str):
return return
# END Class FakeEnchant # 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
+156 -24
View File
@@ -23,6 +23,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
""" """
from __future__ import annotations from __future__ import annotations
import json
import logging import logging
from time import time from time import time
@@ -36,6 +37,7 @@ from novelwriter.common import minmax
from novelwriter.constants import nwFiles from novelwriter.constants import nwFiles
from novelwriter.core.document import NWDocument from novelwriter.core.document import NWDocument
from novelwriter.core.projectxml import ProjectXMLReader, ProjectXMLWriter from novelwriter.core.projectxml import ProjectXMLReader, ProjectXMLWriter
from novelwriter.core.spellcheck import UserDictionary
if TYPE_CHECKING: # pragma: no cover if TYPE_CHECKING: # pragma: no cover
from novelwriter.core.project import NWProject from novelwriter.core.project import NWProject
@@ -87,13 +89,11 @@ class NWStorage:
"""Return the path used for project content. The folder must """Return the path used for project content. The folder must
already exist, otherwise this property is None. already exist, otherwise this property is None.
""" """
if self._runtimePath is not None: if isinstance(self._runtimePath, Path):
contentPath = self._runtimePath / "content" contentPath = self._runtimePath / "content"
if contentPath.is_dir(): if contentPath.is_dir():
return contentPath return contentPath
else: logger.error("Content path cannot be resolved")
logger.error("Path not found: %s", contentPath)
return None
return None return None
## ##
@@ -142,7 +142,6 @@ class NWStorage:
if self._openMode == self.MODE_INPLACE: if self._openMode == self.MODE_INPLACE:
# Nothing to do, so we just return # Nothing to do, so we just return
return True return True
return True return True
def closeSession(self): def closeSession(self):
@@ -157,28 +156,26 @@ class NWStorage:
def getXmlReader(self) -> ProjectXMLReader | None: def getXmlReader(self) -> ProjectXMLReader | None:
"""Return a properly configured ProjectXMLReader instance.""" """Return a properly configured ProjectXMLReader instance."""
if self._runtimePath is None: if isinstance(self._runtimePath, Path):
return None projFile = self._runtimePath / nwFiles.PROJ_FILE
projFile = self._runtimePath / nwFiles.PROJ_FILE return ProjectXMLReader(projFile)
xmlReader = ProjectXMLReader(projFile) return None
return xmlReader
def getXmlWriter(self) -> ProjectXMLWriter | None: def getXmlWriter(self) -> ProjectXMLWriter | None:
"""Return a properly configured ProjectXMLWriter instance.""" """Return a properly configured ProjectXMLWriter instance."""
if self._runtimePath is None: if isinstance(self._runtimePath, Path):
return None return ProjectXMLWriter(self._runtimePath)
xmlWriter = ProjectXMLWriter(self._runtimePath) return None
return xmlWriter
def getDocument(self, tHandle: str | None) -> NWDocument: def getDocument(self, tHandle: str | None) -> NWDocument:
"""Return a document wrapper object.""" """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, tHandle)
return NWDocument(self._project, None) return NWDocument(self._project, None)
def getMetaFile(self, fileName: str) -> Path | None: def getMetaFile(self, fileName: str) -> Path | None:
"""Return the path to a file in the project meta folder.""" """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 self._runtimePath / "meta" / fileName
return None return None
@@ -253,8 +250,8 @@ class NWStorage:
(baseMeta / nwFiles.BUILDS_FILE, f"meta/{nwFiles.BUILDS_FILE}"), (baseMeta / nwFiles.BUILDS_FILE, f"meta/{nwFiles.BUILDS_FILE}"),
(baseMeta / nwFiles.INDEX_FILE, f"meta/{nwFiles.INDEX_FILE}"), (baseMeta / nwFiles.INDEX_FILE, f"meta/{nwFiles.INDEX_FILE}"),
(baseMeta / nwFiles.OPTS_FILE, f"meta/{nwFiles.OPTS_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_STATS, f"meta/{nwFiles.SESS_STATS}"), (baseMeta / nwFiles.SESS_FILE, f"meta/{nwFiles.SESS_FILE}"),
] ]
for contItem in baseCont.iterdir(): for contItem in baseCont.iterdir():
name = contItem.name name = contItem.name
@@ -319,13 +316,15 @@ class NWStorage:
# need for the remaning checks. # need for the remaning checks.
return True return True
legacy = _LegacyStorage(self._project)
# Check for legacy data folders # Check for legacy data folders
for child in path.iterdir(): for child in path.iterdir():
if child.is_dir() and child.name.startswith("data_"): 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 # Check for no longer used files, and delete them
self._deleteDeprecatedFiles(path) legacy.deprecatedFiles(path)
return True return True
@@ -333,7 +332,21 @@ class NWStorage:
# Legacy Project Data Handlers # 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 """Handle the content of a legacy data folder from a version 1.0
project. project.
""" """
@@ -372,9 +385,23 @@ class NWStorage:
return return
def _deleteDeprecatedFiles(self, path: Path): def deprecatedFiles(self, path: Path):
"""Delete files that are no longer used by novelWriter.""" """Handle files that are no longer used by novelWriter."""
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 = [ remove = [
path / "meta" / "tagsIndex.json", # Renamed in 2.1 Beta 1
path / "meta" / "mainOptions.json", # Replaced in 0.5 path / "meta" / "mainOptions.json", # Replaced in 0.5
path / "meta" / "exportOptions.json", # Replaced in 0.5 path / "meta" / "exportOptions.json", # Replaced in 0.5
path / "meta" / "outlineOptions.json", # Replaced in 0.5 path / "meta" / "outlineOptions.json", # Replaced in 0.5
@@ -395,6 +422,111 @@ class NWStorage:
logger.info("Deleted: %s", item) logger.info("Deleted: %s", item)
except Exception as exc: except Exception as exc:
logger.warning("Failed to delete: %s", item, exc_info=exc) logger.warning("Failed to delete: %s", item, exc_info=exc)
return return
# END Class NWStorage ##
# Internal Functions
##
def _convertOldWordList(self, wordList: Path, wordJson: Path):
"""Convert the old word list plain text file to new format."""
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)
# 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
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
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()
nBits = len(bits)
if record.startswith("# Offset") and nBits == 3:
offset = int(bits[2])
elif not record.startswith("#") and nBits > 5:
data.append(session.createRecord(
start=f"{bits[0]} {bits[1]}",
end=f"{bits[2]} {bits[3]}",
novel=int(bits[4]),
notes=int(bits[5]),
idle=int(bits[6]) if nBits > 6 else -1,
))
with open(sessJson, mode="a+", encoding="utf-8") as fObj:
fObj.write(session.createInitial(offset))
fObj.write("".join(data))
# If we're here, we remove the old file
sessLog.unlink()
except Exception:
logger.error("Failed to convert old stats file")
logException()
return
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
+1 -1
View File
@@ -114,7 +114,7 @@ class GuiAbout(QDialog):
return return
def __del__(self): def __del__(self): # pragma: no cover
logger.debug("Delete: GuiAbout") logger.debug("Delete: GuiAbout")
return return
+1 -1
View File
@@ -113,7 +113,7 @@ class GuiDocMerge(QDialog):
return return
def __del__(self): def __del__(self): # pragma: no cover
logger.debug("Delete: GuiDocMerge") logger.debug("Delete: GuiDocMerge")
return return
+1 -1
View File
@@ -142,7 +142,7 @@ class GuiDocSplit(QDialog):
return return
def __del__(self): def __del__(self): # pragma: no cover
logger.debug("Delete: GuiDocSplit") logger.debug("Delete: GuiDocSplit")
return return
+1 -1
View File
@@ -87,7 +87,7 @@ class GuiPreferences(NPagedDialog):
return return
def __del__(self): def __del__(self): # pragma: no cover
logger.debug("Delete: GuiPreferences") logger.debug("Delete: GuiPreferences")
return return
+1 -1
View File
@@ -82,7 +82,7 @@ class GuiProjectDetails(NPagedDialog):
return return
def __del__(self): def __del__(self): # pragma: no cover
logger.debug("Delete: GuiProjectDetails") logger.debug("Delete: GuiProjectDetails")
return return
+1 -1
View File
@@ -151,7 +151,7 @@ class GuiProjectLoad(QDialog):
return return
def __del__(self): def __del__(self): # pragma: no cover
logger.debug("Delete: GuiProjectLoad") logger.debug("Delete: GuiProjectLoad")
return return
+1 -1
View File
@@ -97,7 +97,7 @@ class GuiProjectSettings(NPagedDialog):
return return
def __del__(self): def __del__(self): # pragma: no cover
logger.debug("Delete: GuiProjectSettings") logger.debug("Delete: GuiProjectSettings")
return return
+1 -1
View File
@@ -117,7 +117,7 @@ class GuiUpdates(QDialog):
return return
def __del__(self): def __del__(self): # pragma: no cover
logger.debug("Delete: GuiUpdates") logger.debug("Delete: GuiUpdates")
return return
+38 -66
View File
@@ -1,7 +1,6 @@
""" """
novelWriter GUI User Wordlist novelWriter GUI User Wordlist
=============================== ===============================
Class holding the user's wordlist dialog
File History: File History:
Created: 2021-02-12 [1.2rc1] 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 You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>. along with this program. If not, see <https://www.gnu.org/licenses/>.
""" """
from __future__ import annotations
import logging import logging
from pathlib import Path from typing import TYPE_CHECKING
from PyQt5.QtCore import Qt from PyQt5.QtCore import Qt
from PyQt5.QtWidgets import ( from PyQt5.QtWidgets import (
QDialog, QDialogButtonBox, QVBoxLayout, QHBoxLayout, QListWidget, QAbstractItemView, QDialog, QDialogButtonBox, QHBoxLayout, QLabel,
QAbstractItemView, QPushButton, QLineEdit, QLabel QLineEdit, QListWidget, QListWidgetItem, QPushButton, QVBoxLayout
) )
from novelwriter import CONFIG from novelwriter import CONFIG
from novelwriter.enum import nwAlert from novelwriter.enum import nwAlert
from novelwriter.error import logException from novelwriter.core.spellcheck import UserDictionary
from novelwriter.constants import nwFiles
if TYPE_CHECKING: # pragma: no cover
from novelwriter.guimain import GuiMain
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class GuiWordList(QDialog): class GuiWordList(QDialog):
def __init__(self, mainGui): def __init__(self, mainGui: GuiMain):
super().__init__(parent=mainGui) super().__init__(parent=mainGui)
logger.debug("Create: GuiWordList") logger.debug("Create: GuiWordList")
@@ -112,7 +114,7 @@ class GuiWordList(QDialog):
return return
def __del__(self): def __del__(self): # pragma: no cover
logger.debug("Delete: GuiWordList") logger.debug("Delete: GuiWordList")
return return
@@ -121,66 +123,48 @@ class GuiWordList(QDialog):
## ##
def _doAdd(self): def _doAdd(self):
"""Add a new word to the word list. """Add a new word to the word list."""
""" word = self.newEntry.text().strip()
newWord = self.newEntry.text().strip() if word == "":
if newWord == "":
self.mainGui.makeAlert(self.tr( self.mainGui.makeAlert(self.tr(
"Cannot add a blank word." "Cannot add a blank word."
), nwAlert.ERROR) ), nwAlert.ERROR)
return False return
if self.listBox.findItems(newWord, Qt.MatchExactly): if self.listBox.findItems(word, Qt.MatchExactly):
self.mainGui.makeAlert(self.tr( self.mainGui.makeAlert(self.tr(
"The word '{0}' is already in the word list." "The word '{0}' is already in the word list."
).format(newWord), nwAlert.ERROR) ).format(word), nwAlert.ERROR)
return False return
self.listBox.addItem(newWord) self.listBox.addItem(word)
self.newEntry.setText("") self.newEntry.setText("")
return True return
def _doDelete(self): def _doDelete(self):
"""Delete the selected item. """Delete the selected item."""
"""
selItem = self.listBox.selectedItems() selItem = self.listBox.selectedItems()
if selItem: if selItem:
self.listBox.takeItem(self.listBox.row(selItem[0])) self.listBox.takeItem(self.listBox.row(selItem[0]))
return return
def _doSave(self): def _doSave(self):
"""Save the new word list and close. """Save the new word list and close."""
"""
self._saveGuiSettings() self._saveGuiSettings()
userDict = UserDictionary(self.theProject)
dctFile = self.theProject.storage.getMetaFile(nwFiles.PROJ_DICT) for i in range(self.listBox.count()):
if not isinstance(dctFile, Path): item = self.listBox.item(i)
return False if isinstance(item, QListWidgetItem):
word = item.text().strip()
tmpFile = dctFile.with_suffix(".tmp") if word:
try: userDict.add(word)
with open(tmpFile, mode="w", encoding="utf-8") as outFile: userDict.save()
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
self.accept() self.accept()
return True return True
def _doClose(self): def _doClose(self):
"""Close without saving the word list. """Close without saving the word list."""
"""
self._saveGuiSettings() self._saveGuiSettings()
self.reject() self.reject()
return return
@@ -190,29 +174,17 @@ class GuiWordList(QDialog):
## ##
def _loadWordList(self): def _loadWordList(self):
"""Load the project's word list, if it exists. """Load the project's word list, if it exists."""
""" userDict = UserDictionary(self.theProject)
wordList = self.theProject.storage.getMetaFile(nwFiles.PROJ_DICT) userDict.load()
if not isinstance(wordList, Path):
return False
self.listBox.clear() self.listBox.clear()
if not wordList.exists(): for word in userDict:
logger.debug("No project dictionary file found") if word:
return False self.listBox.addItem(word)
return
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
def _saveGuiSettings(self): def _saveGuiSettings(self):
"""Save GUI settings. """Save GUI settings."""
"""
winWidth = CONFIG.rpxInt(self.width()) winWidth = CONFIG.rpxInt(self.width())
winHeight = CONFIG.rpxInt(self.height()) winHeight = CONFIG.rpxInt(self.height())
+10 -3
View File
@@ -27,6 +27,7 @@ import sys
import random import random
import logging import logging
from PyQt5.QtGui import QFont, QFontDatabase
from PyQt5.QtCore import Qt from PyQt5.QtCore import Qt
from PyQt5.QtWidgets import ( from PyQt5.QtWidgets import (
qApp, QDialog, QGridLayout, QStyle, QPlainTextEdit, QLabel, qApp, QDialog, QGridLayout, QStyle, QPlainTextEdit, QLabel,
@@ -74,7 +75,12 @@ class NWErrorMessage(QDialog):
self.msgHead.setOpenExternalLinks(True) self.msgHead.setOpenExternalLinks(True)
self.msgHead.setWordWrap(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 = QPlainTextEdit()
self.msgBody.setFont(font)
self.msgBody.setReadOnly(True) self.msgBody.setReadOnly(True)
self.btnBox = QDialogButtonBox(QDialogButtonBox.Close) self.btnBox = QDialogButtonBox(QDialogButtonBox.Close)
@@ -89,13 +95,14 @@ class NWErrorMessage(QDialog):
self.mainBox.setSpacing(16) self.mainBox.setSpacing(16)
# Pick a random window title from a set of error messages by # 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([ self.setWindowTitle([
"+++ Out of Cheese Error +++", "+++ Out of Cheese Error +++",
"+++ Divide by Cucumber Error +++", "+++ Divide by Cucumber Error +++",
"+++ Whoops! Here Comes The Cheese! +++", "+++ Whoops! Here Comes The Cheese! +++",
"+++ Please Reinstall Universe and Reboot +++", "+++ Please Reinstall Universe and Reboot +++",
][random.randint(0, 3)]) "+++ Error At Address 14, Treacle Mine Road +++",
][random.randint(0, 4)])
self.setLayout(self.mainBox) self.setLayout(self.mainBox)
@@ -118,7 +125,7 @@ class NWErrorMessage(QDialog):
self.msgHead.setText( self.msgHead.setText(
"<p>An unhandled error has been encountered.</p>" "<p>An unhandled error has been encountered.</p>"
"<p>Please report this error by submitting an issue report on " "<p>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.</p>" "message and traceback shown below.</p>"
f"<p>URL: <a href='{nwConst.URL_REPORT}'>{nwConst.URL_REPORT}</a></p>" f"<p>URL: <a href='{nwConst.URL_REPORT}'>{nwConst.URL_REPORT}</a></p>"
) )
+5 -6
View File
@@ -52,7 +52,7 @@ from PyQt5.QtWidgets import (
from novelwriter import CONFIG from novelwriter import CONFIG
from novelwriter.enum import nwAlert, nwDocAction, nwDocInsert, nwDocMode, nwItemClass from novelwriter.enum import nwAlert, nwDocAction, nwDocInsert, nwDocMode, nwItemClass
from novelwriter.common import minmax, transferCase 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.index import countWords
from novelwriter.core.spellcheck import NWSpellEnchant from novelwriter.core.spellcheck import NWSpellEnchant
from novelwriter.gui.dochighlight import GuiDocHighlighter from novelwriter.gui.dochighlight import GuiDocHighlighter
@@ -131,7 +131,7 @@ class GuiDocEditor(QTextEdit):
self.docSearch = GuiDocEditSearch(self) self.docSearch = GuiDocEditSearch(self)
# Syntax # Syntax
self.spEnchant = NWSpellEnchant() self.spEnchant = NWSpellEnchant(self.theProject)
self.highLight = GuiDocHighlighter(qDoc, self.mainGui, self.spEnchant) self.highLight = GuiDocHighlighter(qDoc, self.mainGui, self.spEnchant)
# Context Menu # Context Menu
@@ -611,9 +611,9 @@ class GuiDocEditor(QTextEdit):
def getText(self): def getText(self):
"""Get the text content of the current document. This method uses """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 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 See: https://doc.qt.io/qt-5/qtextdocument.html#toPlainText
""" """
theText = self.document().toRawText() theText = self.document().toRawText()
@@ -703,8 +703,7 @@ class GuiDocEditor(QTextEdit):
else: else:
theLang = self.theProject.data.spellLang theLang = self.theProject.data.spellLang
projDict = self.theProject.storage.getMetaFile(nwFiles.PROJ_DICT) self.spEnchant.setLanguage(theLang)
self.spEnchant.setLanguage(theLang, projDict)
_, theProvider = self.spEnchant.describeDict() _, theProvider = self.spEnchant.describeDict()
self.spellDictionaryChanged.emit(str(theLang), str(theProvider)) self.spellDictionaryChanged.emit(str(theLang), str(theProvider))
+33 -57
View File
@@ -45,6 +45,7 @@ from novelwriter import CONFIG
from novelwriter.enum import ( from novelwriter.enum import (
nwDocMode, nwItemClass, nwItemLayout, nwItemType, nwOutline nwDocMode, nwItemClass, nwItemLayout, nwItemType, nwOutline
) )
from novelwriter.error import logException
from novelwriter.common import checkInt from novelwriter.common import checkInt
from novelwriter.constants import nwHeaders, trConst, nwKeyWords, nwLabels from novelwriter.constants import nwHeaders, trConst, nwKeyWords, nwLabels
from novelwriter.gui.components import NovelSelector from novelwriter.gui.components import NovelSelector
@@ -78,7 +79,7 @@ class GuiOutlineView(QWidget):
# Assemble # Assemble
self.outerBox = QVBoxLayout() self.outerBox = QVBoxLayout()
self.outerBox.setContentsMargins(0, 0, 0, 0) self.outerBox.setContentsMargins(0, 0, CONFIG.pxInt(4), 0)
self.outerBox.addWidget(self.outlineBar) self.outerBox.addWidget(self.outlineBar)
self.outerBox.addWidget(self.splitOutline) self.outerBox.addWidget(self.splitOutline)
@@ -576,47 +577,33 @@ class GuiOutlineTree(QTreeWidget):
"""Load the state of the main tree header, that is, column order """Load the state of the main tree header, that is, column order
and column width. and column width.
""" """
pOptions = self.theProject.options
# Load whatever we saved last time, regardless of wether it # Load whatever we saved last time, regardless of wether it
# contains the correct names or number of columns. The names # contains the correct names or number of columns.
# must be valid though. colState = self.theProject.options.getValue("GuiOutline", "columnState", {})
tempOrder = pOptions.getValue("GuiOutline", "headerOrder", [])
treeOrder = [] tmpOrder = []
for hName in tempOrder: tmpHidden = {}
try: tmpWidth = {}
treeOrder.append(nwOutline[hName]) try:
except Exception: for name, (hidden, width) in colState.items():
logger.warning("Ignored unknown outline column '%s'", str(hName)) 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. # Add columns that was not in the file to the treeOrder array.
for hItem in nwOutline: for hItem in nwOutline:
if hItem not in treeOrder: if hItem not in tmpOrder:
treeOrder.append(hItem) tmpOrder.append(hItem)
# Check that we now have a complete list, and only if so, save self._treeOrder = tmpOrder
# the order loaded from file. Otherwise, we keep the default. self._colHidden.update(tmpHidden)
if len(treeOrder) == self._treeNCols: self._colWidth.update(tmpWidth)
self._treeOrder = treeOrder
else:
logger.error("Failed to extract outline column order from previous session")
logger.error("Column count doesn't match %d != %d", len(treeOrder), self._treeNCols)
# We load whatever column widths and hidden states we find in
# the file, and leave the rest in their default state.
tmpWidth = pOptions.getValue("GuiOutline", "columnWidth", {})
for hName in tmpWidth:
try:
self._colWidth[nwOutline[hName]] = CONFIG.pxInt(tmpWidth[hName])
except Exception:
logger.warning("Ignored unknown outline column '%s'", str(hName))
tmpHidden = pOptions.getValue("GuiOutline", "columnHidden", {})
for hName in tmpHidden:
try:
self._colHidden[nwOutline[hName]] = tmpHidden[hName]
except Exception:
logger.warning("Ignored unknown outline column '%s'", str(hName))
self.hiddenStateChanged.emit() self.hiddenStateChanged.emit()
@@ -632,30 +619,19 @@ class GuiOutlineTree(QTreeWidget):
if self._lastBuild == 0: if self._lastBuild == 0:
return return
treeOrder = [] colState = {}
colWidth = {}
colHidden = {}
for hItem in nwOutline:
colWidth[hItem.name] = CONFIG.rpxInt(self._colWidth[hItem])
colHidden[hItem.name] = self._colHidden[hItem]
for iCol in range(self.columnCount()): for iCol in range(self.columnCount()):
hName = self._treeOrder[iCol].name hItem = self._treeOrder[iCol]
treeOrder.append(hName)
iLog = self.treeHead.logicalIndex(iCol) iLog = self.treeHead.logicalIndex(iCol)
logWidth = CONFIG.rpxInt(self.columnWidth(iLog))
logHidden = self.isColumnHidden(iLog) logHidden = self.isColumnHidden(iLog)
orgWidth = CONFIG.rpxInt(self._colWidth[hItem])
colHidden[hName] = logHidden logWidth = CONFIG.rpxInt(self.columnWidth(iLog))
if not logHidden and logWidth > 0: colState[hItem.name] = [
colWidth[hName] = logWidth logHidden, orgWidth if logHidden and logWidth == 0 else logWidth
]
pOptions = self.theProject.options pOptions = self.theProject.options
pOptions.setValue("GuiOutline", "headerOrder", treeOrder) pOptions.setValue("GuiOutline", "columnState", colState)
pOptions.setValue("GuiOutline", "columnWidth", colWidth)
pOptions.setValue("GuiOutline", "columnHidden", colHidden)
pOptions.saveSettings() pOptions.saveSettings()
return return
@@ -685,7 +661,7 @@ class GuiOutlineTree(QTreeWidget):
self.setColumnHidden(self._colIdx[nwOutline.TITLE], False) self.setColumnHidden(self._colIdx[nwOutline.TITLE], False)
headItem = self.headerItem() headItem = self.headerItem()
if headItem is not None: if isinstance(headItem, QTreeWidgetItem):
headItem.setTextAlignment(self._colIdx[nwOutline.CCOUNT], Qt.AlignRight) headItem.setTextAlignment(self._colIdx[nwOutline.CCOUNT], Qt.AlignRight)
headItem.setTextAlignment(self._colIdx[nwOutline.WCOUNT], Qt.AlignRight) headItem.setTextAlignment(self._colIdx[nwOutline.WCOUNT], Qt.AlignRight)
headItem.setTextAlignment(self._colIdx[nwOutline.PCOUNT], Qt.AlignRight) headItem.setTextAlignment(self._colIdx[nwOutline.PCOUNT], Qt.AlignRight)
+1 -1
View File
@@ -111,7 +111,7 @@ class GuiLipsum(QDialog):
return return
def __del__(self): def __del__(self): # pragma: no cover
logger.debug("Delete: GuiLipsum") logger.debug("Delete: GuiLipsum")
return return
+3 -2
View File
@@ -196,6 +196,8 @@ class GuiManuscriptBuild(QDialog):
self.mainSplit.setHandleWidth(sp16) self.mainSplit.setHandleWidth(sp16)
self.mainSplit.setCollapsible(0, False) self.mainSplit.setCollapsible(0, False)
self.mainSplit.setCollapsible(1, False) self.mainSplit.setCollapsible(1, False)
self.mainSplit.setStretchFactor(0, 0)
self.mainSplit.setStretchFactor(1, 1)
self.mainSplit.setSizes([ self.mainSplit.setSizes([
CONFIG.pxInt(pOptions.getInt("GuiManuscriptBuild", "fmtWidth", wWin//2)), CONFIG.pxInt(pOptions.getInt("GuiManuscriptBuild", "fmtWidth", wWin//2)),
CONFIG.pxInt(pOptions.getInt("GuiManuscriptBuild", "sumWidth", wWin//2)), CONFIG.pxInt(pOptions.getInt("GuiManuscriptBuild", "sumWidth", wWin//2)),
@@ -233,8 +235,7 @@ class GuiManuscriptBuild(QDialog):
return return
def __del__(self): def __del__(self): # pragma: no cover
"""For debug use only."""
logger.debug("Delete: GuiManuscriptBuild") logger.debug("Delete: GuiManuscriptBuild")
return return
+3 -2
View File
@@ -177,6 +177,8 @@ class GuiManuscript(QDialog):
self.mainSplit = QSplitter() self.mainSplit = QSplitter()
self.mainSplit.addWidget(self.optsWidget) self.mainSplit.addWidget(self.optsWidget)
self.mainSplit.addWidget(self.docPreview) self.mainSplit.addWidget(self.docPreview)
self.mainSplit.setCollapsible(0, False)
self.mainSplit.setCollapsible(1, False)
self.mainSplit.setStretchFactor(0, 0) self.mainSplit.setStretchFactor(0, 0)
self.mainSplit.setStretchFactor(1, 1) self.mainSplit.setStretchFactor(1, 1)
self.mainSplit.setSizes([ self.mainSplit.setSizes([
@@ -194,8 +196,7 @@ class GuiManuscript(QDialog):
return return
def __del__(self): def __del__(self): # pragma: no cover
"""For debug use only."""
logger.debug("Delete: GuiManuscript") logger.debug("Delete: GuiManuscript")
return return
+7 -6
View File
@@ -167,8 +167,7 @@ class GuiBuildSettings(QDialog):
return return
def __del__(self): def __del__(self): # pragma: no cover
"""For debug use only."""
logger.debug("Delete: GuiBuildSettings") logger.debug("Delete: GuiBuildSettings")
def loadContent(self): def loadContent(self):
@@ -371,8 +370,6 @@ class _FilterTab(QWidget):
# ============ # ============
pOptions = self.theProject.options pOptions = self.theProject.options
wTree = CONFIG.pxInt(pOptions.getInt("GuiBuildSettings", "treeWidth", 0))
fTree = CONFIG.pxInt(pOptions.getInt("GuiBuildSettings", "filterWidth", 0))
self.selectionBox = QVBoxLayout() self.selectionBox = QVBoxLayout()
self.selectionBox.addWidget(self.optTree) self.selectionBox.addWidget(self.optTree)
@@ -387,8 +384,12 @@ class _FilterTab(QWidget):
self.mainSplit.addWidget(self.filterOpt) self.mainSplit.addWidget(self.filterOpt)
self.mainSplit.setCollapsible(0, False) self.mainSplit.setCollapsible(0, False)
self.mainSplit.setCollapsible(1, False) self.mainSplit.setCollapsible(1, False)
if wTree > 0: self.mainSplit.setStretchFactor(0, 0)
self.mainSplit.setSizes([wTree, fTree]) self.mainSplit.setStretchFactor(1, 1)
self.mainSplit.setSizes([
CONFIG.pxInt(pOptions.getInt("GuiBuildSettings", "treeWidth", 1)),
CONFIG.pxInt(pOptions.getInt("GuiBuildSettings", "filterWidth", 1))
])
self.outerBox = QHBoxLayout() self.outerBox = QHBoxLayout()
self.outerBox.addWidget(self.mainSplit) self.outerBox.addWidget(self.mainSplit)
+1 -1
View File
@@ -80,7 +80,7 @@ class GuiProjectWizard(QWizard):
return return
def __del__(self): def __del__(self): # pragma: no cover
logger.debug("Delete: GuiProjectWizard") logger.debug("Delete: GuiProjectWizard")
return return
+40 -58
View File
@@ -1,7 +1,6 @@
""" """
novelWriter GUI Writing Statistics novelWriter GUI Writing Statistics
==================================== ====================================
GUI class for the session statistics dialog
File History: File History:
Created: 2019-10-20 [0.3] Created: 2019-10-20 [0.3]
@@ -22,12 +21,13 @@ General Public License for more details.
You should have received a copy of the GNU General Public License You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>. along with this program. If not, see <https://www.gnu.org/licenses/>.
""" """
from __future__ import annotations
import json import json
import logging import logging
from pathlib import Path
from datetime import datetime from datetime import datetime
from typing import TYPE_CHECKING
from PyQt5.QtGui import QPixmap, QCursor from PyQt5.QtGui import QPixmap, QCursor
from PyQt5.QtCore import Qt from PyQt5.QtCore import Qt
@@ -40,13 +40,20 @@ from novelwriter import CONFIG
from novelwriter.enum import nwAlert from novelwriter.enum import nwAlert
from novelwriter.error import formatException from novelwriter.error import formatException
from novelwriter.common import formatTime, checkInt, checkIntTuple, minmax from novelwriter.common import formatTime, checkInt, checkIntTuple, minmax
from novelwriter.constants import nwConst, nwFiles from novelwriter.constants import nwConst
from novelwriter.extensions.switch import NSwitch from novelwriter.extensions.switch import NSwitch
if TYPE_CHECKING: # pragma: no cover
from novelwriter.guimain import GuiMain
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class GuiWritingStats(QDialog): class GuiWritingStats(QDialog):
"""GUI Tools: Writing Statistics
Displays data from the NWSessionLog object.
"""
C_TIME = 0 C_TIME = 0
C_LENGTH = 1 C_LENGTH = 1
@@ -57,7 +64,7 @@ class GuiWritingStats(QDialog):
FMT_JSON = 0 FMT_JSON = 0
FMT_CSV = 1 FMT_CSV = 1
def __init__(self, mainGui): def __init__(self, mainGui: GuiMain):
super().__init__(parent=mainGui) super().__init__(parent=mainGui)
logger.debug("Create: GuiWritingStats") logger.debug("Create: GuiWritingStats")
@@ -290,13 +297,12 @@ class GuiWritingStats(QDialog):
return return
def __del__(self): def __del__(self): # pragma: no cover
logger.debug("Delete: GuiWritingStats") logger.debug("Delete: GuiWritingStats")
return return
def populateGUI(self): def populateGUI(self):
"""Populate list box with data from the log file. """Populate list box with data from the log file."""
"""
qApp.setOverrideCursor(QCursor(Qt.WaitCursor)) qApp.setOverrideCursor(QCursor(Qt.WaitCursor))
self._loadLogFile() self._loadLogFile()
self._updateListBox() self._updateListBox()
@@ -308,8 +314,7 @@ class GuiWritingStats(QDialog):
## ##
def _doClose(self): def _doClose(self):
"""Save the state of the window, clear cache, end close. """Save the state of the window, clear cache, end close."""
"""
self.logData = [] self.logData = []
winWidth = CONFIG.rpxInt(self.width()) winWidth = CONFIG.rpxInt(self.width())
@@ -350,8 +355,7 @@ class GuiWritingStats(QDialog):
return return
def _saveData(self, dataFmt): def _saveData(self, dataFmt):
"""Save the content of the list box to a file. """Save the content of the list box to a file."""
"""
fileExt = "" fileExt = ""
textFmt = "" textFmt = ""
@@ -424,8 +428,7 @@ class GuiWritingStats(QDialog):
## ##
def _loadLogFile(self): def _loadLogFile(self):
"""Load the content of the log file into a buffer. """Load the content of the log file into a buffer."""
"""
logger.debug("Loading session log file") logger.debug("Loading session log file")
self.logData = [] self.logData = []
@@ -436,50 +439,30 @@ class GuiWritingStats(QDialog):
ttTime = 0 ttTime = 0
ttIdle = 0 ttIdle = 0
logFile = self.theProject.storage.getMetaFile(nwFiles.SESS_STATS) for record in self.theProject.session.iterRecords():
if not isinstance(logFile, Path) or not logFile.exists(): rType = record.get("type")
logger.info("This project has no writing stats logfile") if rType == "initial":
return False self.wordOffset = checkInt(record.get("offset"), 0)
logger.debug("Initial word count when log was started is %d" % self.wordOffset)
elif rType == "record":
try:
dStart = datetime.fromisoformat(str(record.get("start")))
dEnd = datetime.fromisoformat(str(record.get("end")))
except Exception:
logger.error("Invalid session log record")
continue
wcNovel = checkInt(record.get("novel"), 0)
wcNotes = checkInt(record.get("notes"), 0)
sIdle = checkInt(record.get("idle"), 0)
try: tDiff = dEnd - dStart
with open(logFile, mode="r", encoding="utf-8") as inFile: sDiff = tDiff.total_seconds()
for inLine in inFile: ttTime += sDiff
if inLine.startswith("#"): ttIdle += sIdle
if inLine.startswith("# Offset"): ttNovel = wcNovel
self.wordOffset = checkInt(inLine[9:].strip(), 0) ttNotes = wcNotes
logger.debug(
"Initial word count when log was started is %d" % self.wordOffset
)
continue
inData = inLine.split() self.logData.append((dStart, sDiff, wcNovel, wcNotes, sIdle))
if len(inData) < 6:
continue
dStart = datetime.fromisoformat(" ".join(inData[0:2]))
dEnd = datetime.fromisoformat(" ".join(inData[2:4]))
sIdle = 0
if len(inData) > 6:
sIdle = checkInt(inData[6], 0)
tDiff = dEnd - dStart
sDiff = tDiff.total_seconds()
ttTime += sDiff
ttIdle += sIdle
wcNovel = int(inData[4])
wcNotes = int(inData[5])
ttNovel = wcNovel
ttNotes = wcNotes
self.logData.append((dStart, sDiff, wcNovel, wcNotes, sIdle))
except Exception as exc:
self.mainGui.makeAlert(self.tr(
"Failed to read session log file."
), nwAlert.ERROR, exception=exc)
return False
ttWords = ttNovel + ttNotes ttWords = ttNovel + ttNotes
self.labelTotal.setText(formatTime(round(ttTime))) self.labelTotal.setText(formatTime(round(ttTime)))
@@ -488,15 +471,14 @@ class GuiWritingStats(QDialog):
self.notesWords.setText(f"{ttNotes:n}") self.notesWords.setText(f"{ttNotes:n}")
self.totalWords.setText(f"{ttWords:n}") self.totalWords.setText(f"{ttWords:n}")
return True return
## ##
# Slots # Slots
## ##
def _updateListBox(self): def _updateListBox(self):
"""Load/reload the content of the list box. """Load/reload the content of the list box."""
"""
self.listBox.clear() self.listBox.clear()
self.timeFilter = 0.0 self.timeFilter = 0.0
+4 -8
View File
@@ -61,8 +61,7 @@ def resetConfigVars():
@pytest.fixture(scope="session", autouse=True) @pytest.fixture(scope="session", autouse=True)
def sessionFixture(): 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(): if _TMP_ROOT.exists():
shutil.rmtree(_TMP_ROOT) shutil.rmtree(_TMP_ROOT)
_TMP_ROOT.mkdir() _TMP_ROOT.mkdir()
@@ -111,8 +110,7 @@ def tstPaths():
@pytest.fixture(scope="function") @pytest.fixture(scope="function")
def fncPath(): def fncPath():
"""A temporary folder for a single test function. """A temporary folder for a single test function."""
"""
fncPath = _TMP_ROOT / "function" fncPath = _TMP_ROOT / "function"
if fncPath.is_dir(): if fncPath.is_dir():
shutil.rmtree(fncPath) shutil.rmtree(fncPath)
@@ -139,16 +137,14 @@ def projPath(fncPath):
@pytest.fixture(scope="function") @pytest.fixture(scope="function")
def mockGUI(): def mockGUI():
"""Create a mock instance of novelWriter's main GUI class. """Create a mock instance of novelWriter's main GUI class."""
"""
theGui = MockGuiMain() theGui = MockGuiMain()
return theGui return theGui
@pytest.fixture(scope="function") @pytest.fixture(scope="function")
def nwGUI(qtbot, monkeypatch, functionFixture): 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, "warning", lambda *a: QMessageBox.Ok)
monkeypatch.setattr(QMessageBox, "critical", lambda *a: QMessageBox.Ok) monkeypatch.setattr(QMessageBox, "critical", lambda *a: QMessageBox.Ok)
monkeypatch.setattr(QMessageBox, "information", lambda *a: QMessageBox.Ok) monkeypatch.setattr(QMessageBox, "information", lambda *a: QMessageBox.Ok)
@@ -1,10 +1,10 @@
{ {
"tagsIndex": { "novelWriter.tagsIndex": {
"Bod": {"handle": "4c4f28287af27", "heading": "T0001", "class": "CHARACTER"}, "Bod": {"handle": "4c4f28287af27", "heading": "T0001", "class": "CHARACTER"},
"Main": {"handle": "2426c6f0ca922", "heading": "T0001", "class": "PLOT"}, "Main": {"handle": "2426c6f0ca922", "heading": "T0001", "class": "PLOT"},
"Europe": {"handle": "04468803b92e1", "heading": "T0001", "class": "WORLD"} "Europe": {"handle": "04468803b92e1", "heading": "T0001", "class": "WORLD"}
}, },
"itemIndex": { "novelWriter.itemIndex": {
"7a992350f3eb6": { "7a992350f3eb6": {
"headings": { "headings": {
"T0001": {"level": "H1", "title": "Lorem Ipsum", "line": 1, "tag": "", "cCount": 230, "wCount": 40, "pCount": 3, "synopsis": ""} "T0001": {"level": "H1", "title": "Lorem Ipsum", "line": 1, "tag": "", "cCount": 230, "wCount": 40, "pCount": 3, "synopsis": ""}
+3 -3
View File
@@ -125,7 +125,7 @@ def testCoreIndex_LoadSave(monkeypatch, prjLipsum, mockGUI, tstPaths):
assert theIndex.indexBroken is True assert theIndex.indexBroken is True
# Write an index file that passes loading, but is still empty # 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.loadIndex() is True
assert theIndex.indexBroken is False assert theIndex.indexBroken is False
@@ -1071,13 +1071,13 @@ def testCoreIndex_ItemIndex(mockGUI, fncPath, mockRnd):
assert nStruct[3][0] == uHandle assert nStruct[3][0] == uHandle
# Novel structure with root handle set # 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 len(nStruct) == 3
assert nStruct[0][0] == nHandle assert nStruct[0][0] == nHandle
assert nStruct[1][0] == cHandle assert nStruct[1][0] == cHandle
assert nStruct[2][0] == sHandle assert nStruct[2][0] == sHandle
nStruct = list(itemIndex.iterNovelStructure(rootHandle=mHandle)) nStruct = list(itemIndex.iterNovelStructure(rHandle=mHandle))
assert len(nStruct) == 1 assert len(nStruct) == 1
assert nStruct[0][0] == uHandle assert nStruct[0][0] == uHandle
+13 -11
View File
@@ -42,15 +42,17 @@ def testCoreOptions_LoadSave(monkeypatch, mockGUI, fncPath):
# Write a test file # Write a test file
optFile = metaDir / nwFiles.OPTS_FILE optFile = metaDir / nwFiles.OPTS_FILE
optFile.write_text(json.dumps({ optFile.write_text(json.dumps({
"GuiProjectSettings": { "novelWriter.guiOptions": {
"winWidth": 570, "GuiProjectSettings": {
"winHeight": 375, "winWidth": 570,
"replaceColW": 130, "winHeight": 375,
"statusColW": 130, "replaceColW": 130,
"importColW": 130 "statusColW": 130,
}, "importColW": 130
"MockGroup": { },
"mockItem": None, "MockGroup": {
"mockItem": None,
},
}, },
}), encoding="utf-8") }), encoding="utf-8")
@@ -73,7 +75,7 @@ def testCoreOptions_LoadSave(monkeypatch, mockGUI, fncPath):
assert theOpts.loadSettings() assert theOpts.loadSettings()
# Check that unwanted items have been removed # Check that unwanted items have been removed
assert theOpts._theState == { assert theOpts._state == {
"GuiProjectSettings": { "GuiProjectSettings": {
"winWidth": 570, "winWidth": 570,
"winHeight": 375, "winHeight": 375,
@@ -88,7 +90,7 @@ def testCoreOptions_LoadSave(monkeypatch, mockGUI, fncPath):
# Load again to check we get the values back # Load again to check we get the values back
assert theOpts.loadSettings() assert theOpts.loadSettings()
assert theOpts._theState == { assert theOpts._state == {
"GuiProjectSettings": { "GuiProjectSettings": {
"winWidth": 570, "winWidth": 570,
"winHeight": 375, "winHeight": 375,
+2 -47
View File
@@ -21,9 +21,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
import pytest import pytest
from time import time
from shutil import copyfile from shutil import copyfile
from pathlib import Path
from zipfile import ZipFile from zipfile import ZipFile
from mocked import causeOSError from mocked import causeOSError
@@ -31,8 +29,6 @@ from tools import C, cmpFiles, writeFile, buildTestProject, XML_IGNORE
from novelwriter import CONFIG from novelwriter import CONFIG
from novelwriter.enum import nwItemClass, nwItemType, nwItemLayout 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.tree import NWTree
from novelwriter.core.index import NWIndex from novelwriter.core.index import NWIndex
from novelwriter.core.project import NWProject from novelwriter.core.project import NWProject
@@ -468,8 +464,7 @@ def testCoreProject_StatusImport(mockGUI, fncPath, mockRnd):
@pytest.mark.core @pytest.mark.core
def testCoreProject_Methods(monkeypatch, mockGUI, fncPath, mockRnd): def testCoreProject_Methods(monkeypatch, mockGUI, fncPath, mockRnd):
"""Test other project class methods and functions. """Test other project class methods and functions."""
"""
theProject = NWProject(mockGUI) theProject = NWProject(mockGUI)
buildTestProject(theProject, fncPath) buildTestProject(theProject, fncPath)
@@ -487,7 +482,7 @@ def testCoreProject_Methods(monkeypatch, mockGUI, fncPath, mockRnd):
# Edit Time # Edit Time
theProject.data.setEditTime(1234) theProject.data.setEditTime(1234)
theProject._projOpened = 1600000000 theProject._session._start = 1600000000
with monkeypatch.context() as mp: with monkeypatch.context() as mp:
mp.setattr("novelwriter.core.project.time", lambda: 1600005600) mp.setattr("novelwriter.core.project.time", lambda: 1600005600)
assert theProject.getCurrentEditTime() == 6834 assert theProject.getCurrentEditTime() == 6834
@@ -578,46 +573,6 @@ def testCoreProject_Methods(monkeypatch, mockGUI, fncPath, mockRnd):
assert theProject.setTreeOrder(oldOrder) assert theProject.setTreeOrder(oldOrder)
assert theProject.tree.handles() == 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._appendSessionStats(idleTime=0) is False
# Block open
with monkeypatch.context() as mp:
mp.setattr("builtins.open", causeOSError)
assert theProject._appendSessionStats(idleTime=0) is False
# Session too short
theProject._projOpened = time()
theProject.data.setInitCounts(50, 50)
theProject.data.setCurrCounts(50, 50)
assert theProject._appendSessionStats(idleTime=0) is False
# Write entry
statsFile = theProject.storage.getMetaFile(nwFiles.SESS_STATS)
assert isinstance(statsFile, Path)
if statsFile.exists():
statsFile.unlink()
theProject._projOpened = 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._appendSessionStats(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 # END Test testCoreProject_Methods
+114
View File
@@ -0,0 +1,114 @@
"""
novelWriter NWSessionLog Class Tester
=======================================
This file is a part of novelWriter
Copyright 20182023, 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 <https://www.gnu.org/licenses/>.
"""
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
+133 -98
View File
@@ -21,34 +21,119 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
import sys import sys
import pytest import pytest
import enchant
from pathlib import Path
from tools import buildTestProject
from mocked import causeOSError 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 @pytest.mark.core
def testCoreSpell_FakeEnchant(monkeypatch): def testCoreSpell_UserDictionary(monkeypatch, mockGUI, fncPath):
"""Test the FakeEnchant spell checker fallback. """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 # Make package import fail
with monkeypatch.context() as mp: with monkeypatch.context() as mp:
mp.setitem(sys.modules, "enchant", None) mp.setitem(sys.modules, "enchant", None)
spChk = NWSpellEnchant() spChk = NWSpellEnchant(project)
spChk.setLanguage("en_US", "") spChk.setLanguage("en_US")
assert isinstance(spChk._theDict, FakeEnchant) assert isinstance(spChk._dictObj, FakeEnchant)
# Request a non-existent dictionary # Request a non-existent dictionary
spChk = NWSpellEnchant() spChk = NWSpellEnchant(project)
spChk.setLanguage("whatchamajig", "") spChk.setLanguage("whatchamajig")
assert isinstance(spChk._theDict, FakeEnchant) assert isinstance(spChk._dictObj, FakeEnchant)
# Request an emety language string # Request an empty language string
# See issue https://github.com/vkbo/novelWriter/issues/1096 # See issue https://github.com/vkbo/novelWriter/issues/1096
spChk = NWSpellEnchant() spChk = NWSpellEnchant(project)
spChk.setLanguage("", "") spChk.setLanguage("")
assert isinstance(spChk._theDict, FakeEnchant) assert isinstance(spChk._dictObj, FakeEnchant)
# FakeEnchant should handle requests # FakeEnchant should handle requests
fkChk = FakeEnchant() fkChk = FakeEnchant()
@@ -62,103 +147,53 @@ def testCoreSpell_FakeEnchant(monkeypatch):
@pytest.mark.core @pytest.mark.core
def testCoreSpell_Enchant(monkeypatch, fncPath): def testCoreSpell_Enchant(monkeypatch, mockGUI, fncPath):
"""Test the pyenchant spell checker. """Test the pyenchant spell checker."""
""" project = NWProject(mockGUI)
wList = fncPath / "wordlist.txt" buildTestProject(project, fncPath)
writeFile(wList, "a_word\nb_word\nc_word\n")
# Break the enchant package, and check error handling # Break the enchant package, and check error handling
with monkeypatch.context() as mp: with monkeypatch.context() as mp:
mp.setitem(sys.modules, "enchant", None) mp.setitem(sys.modules, "enchant", None)
spChk = NWSpellEnchant() spChk = NWSpellEnchant(project)
assert spChk.spellLanguage is None
assert spChk.listDictionaries() == [] assert spChk.listDictionaries() == []
assert spChk.describeDict() == ("", "") assert spChk.describeDict() == ("", "")
# Set the dict to None, and check dictionary call error handling spChk.setLanguage("en_US")
spChk = NWSpellEnchant() assert spChk.spellLanguage is None
spChk.theDict = 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.checkWord("word") is True
assert spChk.suggestWords("word") == [] assert spChk.suggestWords("word") == []
assert spChk.addWord("word") is False assert spChk.addWord("word") is False
assert spChk.addWord("\n\t ") is False
assert spChk.describeDict() == ("", "")
# Load the proper enchant package (twice) # Load the proper enchant package (twice)
spChk = NWSpellEnchant() spChk = NWSpellEnchant(project)
spChk.setLanguage("en_US", wList) spChk.setLanguage("en_US")
spChk.setLanguage("en_US", wList) spChk.setLanguage("en_US")
assert isinstance(spChk._dictObj, enchant.Dict)
assert spChk.spellLanguage == "en_US" assert spChk.spellLanguage == "en_US"
assert spChk.listDictionaries() != []
assert spChk.describeDict() != ("", "")
# Add a word to the user's dictionary # Set to non-existent language
assert spChk._readProjectDictionary("stuff") is False spChk.setLanguage("foo_bar")
# Block the broker from figuring out the language
with monkeypatch.context() as mp: with monkeypatch.context() as mp:
mp.setattr("builtins.open", causeOSError) mp.setattr("enchant.Broker.request_dict", lambda *a: None)
assert spChk._readProjectDictionary(wList) is False spChk.setLanguage("en_US")
assert isinstance(spChk._dictObj, FakeEnchant)
assert spChk._readProjectDictionary(None) is False
assert spChk._readProjectDictionary(wList) is True
assert spChk._projectDict == 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 != ""
# END Test testCoreSpell_Enchant # 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
+183 -40
View File
@@ -19,22 +19,24 @@ You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>. along with this program. If not, see <https://www.gnu.org/licenses/>.
""" """
from zipfile import ZipFile import json
import pytest import pytest
from pathlib import Path
from zipfile import ZipFile
from tools import C, buildTestProject, writeFile from tools import C, buildTestProject, writeFile
from mocked import causeOSError from mocked import causeOSError
from novelwriter import CONFIG from novelwriter import CONFIG
from novelwriter.constants import nwFiles from novelwriter.constants import nwFiles
from novelwriter.core.project import NWProject 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 from novelwriter.core.projectxml import ProjectXMLReader, ProjectXMLWriter
class MockProject: class MockProject:
"""Test class for projects.""" """Test class for projects."""
pass pass
@@ -112,7 +114,7 @@ def testCoreStorage_LockFile(monkeypatch, fncPath):
"""Test the project lock file.""" """Test the project lock file."""
monkeypatch.setattr("novelwriter.core.storage.time", lambda: 1000.0) monkeypatch.setattr("novelwriter.core.storage.time", lambda: 1000.0)
storage = NWStorage(MockProject()) storage = NWStorage(MockProject()) # type: ignore
assert storage.isOpen() is False assert storage.isOpen() is False
# Project not open, so cannot read/write lock file # Project not open, so cannot read/write lock file
@@ -169,10 +171,46 @@ def testCoreStorage_LockFile(monkeypatch, fncPath):
# END Test testCoreStorage_LockFile # 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 @pytest.mark.core
def testCoreStorage_PrepareStorage(monkeypatch, fncPath): def testCoreStorage_PrepareStorage(monkeypatch, fncPath):
"""Test the project path preparation functions.""" """Test the project path preparation functions."""
storage = NWStorage(MockProject()) storage = NWStorage(MockProject()) # type: ignore
assert storage.isOpen() is False assert storage.isOpen() is False
# No path set # No path set
@@ -208,9 +246,18 @@ def testCoreStorage_PrepareStorage(monkeypatch, fncPath):
storage._runtimePath = fncPath storage._runtimePath = fncPath
assert storage._prepareStorage(checkLegacy=False, newProject=True) is False 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 storage._runtimePath = fncPath
assert storage._prepareStorage() is True
legacy = _LegacyStorage(project) # type: ignore
data = [] data = []
files = [] files = []
@@ -235,7 +282,7 @@ def testCoreStorage_PrepareStorage(monkeypatch, fncPath):
# Process folders # Process folders
for i in range(9): 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 # Files form 0 to 8 should now be in content
for c in "012345678": for c in "012345678":
@@ -250,14 +297,14 @@ def testCoreStorage_PrepareStorage(monkeypatch, fncPath):
assert data[8].exists() assert data[8].exists()
# So does folder X, which is invalid # So does folder X, which is invalid
storage._legacyDataFolder(fncPath, data[16]) legacy.legacyDataFolder(fncPath, data[16])
assert data[16].exists() assert data[16].exists()
# Fail cleanup of folder 9 # Fail cleanup of folder 9
with monkeypatch.context() as mp: with monkeypatch.context() as mp:
mp.setattr("pathlib.Path.rename", causeOSError) mp.setattr("pathlib.Path.rename", causeOSError)
mp.setattr("pathlib.Path.unlink", causeOSError) mp.setattr("pathlib.Path.unlink", causeOSError)
storage._legacyDataFolder(fncPath, data[9]) legacy.legacyDataFolder(fncPath, data[9])
assert data[9].exists() assert data[9].exists()
assert not (fncPath / "content" / "9000000000009.nwd").exists() assert not (fncPath / "content" / "9000000000009.nwd").exists()
@@ -266,10 +313,24 @@ def testCoreStorage_PrepareStorage(monkeypatch, fncPath):
for c in "0123456789abcdef": for c in "0123456789abcdef":
assert (fncPath / "content" / f"{c}00000000000{c}.nwd").exists() 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 = [ remove = [
fncPath / "meta" / "tagsIndex.json",
fncPath / "meta" / "mainOptions.json", fncPath / "meta" / "mainOptions.json",
fncPath / "meta" / "exportOptions.json", fncPath / "meta" / "exportOptions.json",
fncPath / "meta" / "outlineOptions.json", fncPath / "meta" / "outlineOptions.json",
@@ -286,48 +347,130 @@ def testCoreStorage_PrepareStorage(monkeypatch, fncPath):
with monkeypatch.context() as mp: with monkeypatch.context() as mp:
mp.setattr("pathlib.Path.unlink", causeOSError) mp.setattr("pathlib.Path.unlink", causeOSError)
storage._deleteDeprecatedFiles(fncPath) legacy.deprecatedFiles(fncPath)
for depFile in remove: for depFile in remove:
assert depFile.exists() assert depFile.exists()
storage._deleteDeprecatedFiles(fncPath) legacy.deprecatedFiles(fncPath)
for depFile in remove: for depFile in remove:
assert not depFile.exists() assert not depFile.exists()
# END Test testCoreStorage_PrepareStorage # END Test testCoreStorage_DeprecatedFiles
@pytest.mark.core @pytest.mark.core
def testCoreStorage_ZipIt(monkeypatch, mockGUI, fncPath, tstPaths, mockRnd): def testCoreStorage_OldFormatConvert(monkeypatch, mockGUI, fncPath):
"""Test making a zip archive of a project.""" """Test cleanup of deprecated files that needs to be converted."""
zipFile = tstPaths.tmpDir / "project.zip" project = NWProject(mockGUI)
buildTestProject(project, fncPath)
legacy = _LegacyStorage(project)
theProject = NWProject(mockGUI) # The build project functions saves the project, so we must delete
storage = theProject.storage # the old gui options file
assert storage.zipIt(zipFile) is False (fncPath / "meta" / nwFiles.OPTS_FILE).unlink()
# Make a project # Word List
mockRnd.reset() wordListOld: Path = fncPath / "meta" / "wordlist.txt"
buildTestProject(theProject, fncPath) 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: with monkeypatch.context() as mp:
mp.setattr("novelwriter.core.storage.ZipFile.write", causeOSError) mp.setattr("builtins.open", causeOSError)
assert storage.zipIt(zipFile) is False 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 # Check Success
assert storage.zipIt(zipFile) is True 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 # Check Word List
with ZipFile(zipFile, mode="r") as archive: data = json.loads(wordListNew.read_text(encoding="utf-8"))
names = archive.namelist() assert "word_a" in data["novelWriter.userDict"]
assert nwFiles.PROJ_FILE in names assert "word_b" in data["novelWriter.userDict"]
assert f"meta/{nwFiles.OPTS_FILE}" in names assert "word_c" in data["novelWriter.userDict"]
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() # 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
+29 -31
View File
@@ -24,10 +24,9 @@ import pytest
from PyQt5.QtCore import Qt from PyQt5.QtCore import Qt
from PyQt5.QtWidgets import QDialog, QAction from PyQt5.QtWidgets import QDialog, QAction
from tools import buildTestProject, writeFile, readFile, getGuiItem from tools import buildTestProject, getGuiItem
from mocked import causeOSError
from novelwriter.constants import nwFiles from novelwriter.core.spellcheck import UserDictionary
from novelwriter.dialogs.wordlist import GuiWordList from novelwriter.dialogs.wordlist import GuiWordList
@@ -43,7 +42,6 @@ def testDlgWordList_Dialog(qtbot, monkeypatch, nwGUI, projPath):
# Open project # Open project
nwGUI.openProject(projPath) nwGUI.openProject(projPath)
dictFile = projPath / "meta" / nwFiles.PROJ_DICT
# Load the dialog # Load the dialog
nwGUI.mainMenu.aEditWordList.activate(QAction.Trigger) nwGUI.mainMenu.aEditWordList.activate(QAction.Trigger)
@@ -57,15 +55,15 @@ def testDlgWordList_Dialog(qtbot, monkeypatch, nwGUI, projPath):
assert wList.listBox.count() == 0 assert wList.listBox.count() == 0
# Add words # Add words
writeFile(dictFile, ( userDict = UserDictionary(nwGUI.theProject)
"word_a\n" userDict.add("word_a")
"word_c\n" userDict.add("word_c")
"word_g\n" userDict.add("word_g")
" \n" # Should be ignored userDict.add("word_f")
"word_f\n" userDict.add("word_b")
"word_b\n" userDict.save()
))
assert wList._loadWordList() wList._loadWordList()
# Check that the content was loaded # Check that the content was loaded
assert wList.listBox.item(0).text() == "word_a" assert wList.listBox.item(0).text() == "word_a"
@@ -73,18 +71,22 @@ def testDlgWordList_Dialog(qtbot, monkeypatch, nwGUI, projPath):
assert wList.listBox.item(2).text() == "word_c" assert wList.listBox.item(2).text() == "word_c"
assert wList.listBox.item(3).text() == "word_f" assert wList.listBox.item(3).text() == "word_f"
assert wList.listBox.item(4).text() == "word_g" 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(" ") 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") wList.newEntry.setText("word_c")
assert not wList._doAdd() wList._doAdd()
assert wList.listBox.count() == 5
# Add a new word # Add a new word
wList.newEntry.setText("word_d") wList.newEntry.setText("word_d")
assert wList._doAdd() wList._doAdd()
assert wList.listBox.count() == 6
# Check that the content now # Check that the content now
assert wList.listBox.item(0).text() == "word_a" assert wList.listBox.item(0).text() == "word_a"
@@ -96,7 +98,7 @@ def testDlgWordList_Dialog(qtbot, monkeypatch, nwGUI, projPath):
# Delete a word # Delete a word
wList.newEntry.setText("delete_me") wList.newEntry.setText("delete_me")
assert wList._doAdd() wList._doAdd()
assert wList.listBox.item(0).text() == "delete_me" assert wList.listBox.item(0).text() == "delete_me"
delItem = wList.listBox.findItems("delete_me", Qt.MatchExactly)[0] delItem = wList.listBox.findItems("delete_me", Qt.MatchExactly)[0]
@@ -108,18 +110,14 @@ def testDlgWordList_Dialog(qtbot, monkeypatch, nwGUI, projPath):
# Save files # Save files
assert wList._doSave() assert wList._doSave()
assert readFile(dictFile) == ( userDict.load()
"word_a\n" assert len(list(userDict)) == 6
"word_b\n" assert "word_a" in userDict
"word_c\n" assert "word_b" in userDict
"word_d\n" assert "word_c" in userDict
"word_f\n" assert "word_d" in userDict
"word_g\n" assert "word_f" in userDict
) assert "word_g" in userDict
# Save again and make it fail
monkeypatch.setattr("builtins.open", causeOSError)
assert not wList._doSave()
# qtbot.stop() # qtbot.stop()
wList._doClose() wList._doClose()
+35 -26
View File
@@ -33,8 +33,7 @@ from novelwriter.enum import nwItemClass, nwOutline, nwView
@pytest.mark.gui @pytest.mark.gui
def testGuiOutline_Main(qtbot, monkeypatch, nwGUI, projPath): def testGuiOutline_Main(qtbot, monkeypatch, nwGUI, projPath):
"""Test the outline view. """Test the outline view."""
"""
# Create a project # Create a project
buildTestProject(nwGUI, projPath) buildTestProject(nwGUI, projPath)
@@ -83,52 +82,61 @@ def testGuiOutline_Main(qtbot, monkeypatch, nwGUI, projPath):
# Save header state not allowed # Save header state not allowed
outlineTree._lastBuild = 0 outlineTree._lastBuild = 0
outlineTree._saveHeaderState() outlineTree._saveHeaderState()
assert pOptions.getValue("GuiOutline", "headerOrder", []) == [] assert pOptions.getValue("GuiOutline", "columnState", {}) == {}
# Allow saving header state # Allow saving header state
outlineTree._lastBuild = time.time() outlineTree._lastBuild = time.time()
outlineTree._saveHeaderState() outlineTree._saveHeaderState()
assert pOptions.getValue("GuiOutline", "headerOrder", []) == colNames assert list(pOptions.getValue("GuiOutline", "columnState", {}).keys()) == colNames
assert outlineTree._treeOrder == colItems assert outlineTree._treeOrder == colItems
assert outlineTree._colWidth == colWidth assert outlineTree._colWidth == colWidth
assert outlineTree._colHidden == colHidden assert outlineTree._colHidden == colHidden
# Get default values # Get default values
optItems = pOptions.getValue("GuiOutline", "headerOrder", []) columnState = pOptions.getValue("GuiOutline", "columnState", {})
optWidth = pOptions.getValue("GuiOutline", "columnWidth", {})
optHidden = pOptions.getValue("GuiOutline", "columnHidden", {})
# Add invalid column name # Add invalid column name
pOptions.setValue("GuiOutline", "headerOrder", optItems + ["blabla"]) newState = columnState.copy()
outlineTree._loadHeaderState() newState.update({"blabla": (False, 42)})
assert outlineTree._treeOrder == colItems pOptions.setValue("GuiOutline", "columnState", newState)
assert outlineTree._colHidden == colHidden
# Add duplicate column name
pOptions.setValue("GuiOutline", "headerOrder", optItems + [optItems[-1]])
outlineTree._loadHeaderState() outlineTree._loadHeaderState()
assert outlineTree._treeOrder == colItems assert outlineTree._treeOrder == colItems
assert outlineTree._colHidden == colHidden assert outlineTree._colHidden == colHidden
# Invalid column width data # Invalid column width data
pOptions.setValue("GuiOutline", "headerOrder", optItems) newState = columnState.copy()
pOptions.setValue("GuiOutline", "columnWidth", {"blabla": None}) newState.update({"TITLE": (False, None)})
pOptions.setValue("GuiOutline", "columnState", newState)
outlineTree._loadHeaderState() outlineTree._loadHeaderState()
assert outlineTree._treeOrder == colItems assert outlineTree._treeOrder == colItems
assert outlineTree._colHidden == colHidden assert outlineTree._colHidden == colHidden
# Invalid column width data # Invalid column state data
pOptions.setValue("GuiOutline", "headerOrder", optItems) newState = columnState.copy()
pOptions.setValue("GuiOutline", "columnWidth", optWidth) newState.update({"TITLE": None})
pOptions.setValue("GuiOutline", "columnHidden", {"bloabla": None}) pOptions.setValue("GuiOutline", "columnState", newState)
outlineTree._loadHeaderState() outlineTree._loadHeaderState()
assert outlineTree._treeOrder == colItems assert outlineTree._treeOrder == colItems
assert outlineTree._colHidden == colHidden 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 # Valid settings
pOptions.setValue("GuiOutline", "headerOrder", optItems) pOptions.setValue("GuiOutline", "columnState", columnState)
pOptions.setValue("GuiOutline", "columnWidth", optWidth)
pOptions.setValue("GuiOutline", "columnHidden", optHidden)
outlineTree._loadHeaderState() outlineTree._loadHeaderState()
assert outlineTree._treeOrder == colItems assert outlineTree._treeOrder == colItems
assert outlineTree._colHidden == colHidden assert outlineTree._colHidden == colHidden
@@ -143,7 +151,9 @@ def testGuiOutline_Main(qtbot, monkeypatch, nwGUI, projPath):
# Now no columns should be hidden # Now no columns should be hidden
outlineTree._saveHeaderState() 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() # qtbot.stop()
@@ -152,8 +162,7 @@ def testGuiOutline_Main(qtbot, monkeypatch, nwGUI, projPath):
@pytest.mark.gui @pytest.mark.gui
def testGuiOutline_Content(qtbot, nwGUI, prjLipsum): def testGuiOutline_Content(qtbot, nwGUI, prjLipsum):
"""Test the outline view. """Test the outline view."""
"""
assert nwGUI.openProject(prjLipsum) assert nwGUI.openProject(prjLipsum)
nwGUI.rebuildIndex() nwGUI.rebuildIndex()
+29 -40
View File
@@ -20,10 +20,11 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
""" """
import json import json
from pathlib import Path
import pytest import pytest
from tools import getGuiItem, buildTestProject
from mocked import causeOSError from mocked import causeOSError
from tools import getGuiItem, writeFile, buildTestProject
from PyQt5.QtCore import Qt from PyQt5.QtCore import Qt
from PyQt5.QtWidgets import QAction, QFileDialog from PyQt5.QtWidgets import QAction, QFileDialog
@@ -38,9 +39,11 @@ def testToolWritingStats_Main(qtbot, monkeypatch, nwGUI, projPath, tstPaths):
""" """
# Create a project to work on # Create a project to work on
buildTestProject(nwGUI, projPath) buildTestProject(nwGUI, projPath)
project = nwGUI.theProject
qtbot.wait(100) qtbot.wait(100)
assert nwGUI.saveProject() assert nwGUI.saveProject()
sessFile = projPath / "meta" / nwFiles.SESS_STATS sessFile: Path = projPath / "meta" / nwFiles.SESS_FILE
# Open the Writing Stats dialog # Open the Writing Stats dialog
nwGUI.mainMenu.aWritingStats.activate(QAction.Trigger) nwGUI.mainMenu.aWritingStats.activate(QAction.Trigger)
@@ -54,52 +57,38 @@ def testToolWritingStats_Main(qtbot, monkeypatch, nwGUI, projPath, tstPaths):
# No initial logfile # No initial logfile
assert not sessFile.is_file() assert not sessFile.is_file()
assert not sessLog._loadLogFile() assert list(project.session.iterRecords()) == []
# Make a test log file # Make a test log file
writeFile(sessFile, ( data = [
"# Offset 123\n" project.session.createInitial(123),
"# Start Time End Time Novel Notes Idle\n" project.session.createRecord("2020-01-01 21:00:00", "2020-01-01 21:00:05", 6, 0, 0),
"2020-01-01 21:00:00 2020-01-01 21:00:05 6 0\n" project.session.createRecord("2020-01-03 21:00:00", "2020-01-03 21:00:15", 125, 0, 0),
"2020-01-03 21:00:00 2020-01-03 21:00:15 125 0\n" project.session.createRecord("2020-01-03 21:30:00", "2020-01-03 21:30:15", 125, 5, 0),
"2020-01-03 21:30:00 2020-01-03 21:30:15 125 5\n" project.session.createRecord("2020-01-06 21:00:00", "2020-01-06 21:00:10", 125, 5, 0),
"2020-01-06 21:00:00 2020-01-06 21:00:10 125 5\n" ]
)) sessFile.write_text("".join(data), encoding="utf-8")
assert sessFile.is_file() sessLog._loadLogFile()
assert sessLog._loadLogFile()
assert sessLog.wordOffset == 123 assert sessLog.wordOffset == 123
assert len(sessLog.logData) == 4 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 # Test Exporting
# ============== # ==============
writeFile(sessFile, ( data = [
"# Offset 1075\n" project.session.createInitial(1075),
"# Start Time End Time Novel Notes Idle\n" project.session.createRecord("2021-01-31 19:00:00", "2021-01-31 19:30:00", 700, 375, 0),
"2021-01-31 19:00:00 2021-01-31 19:30:00 700 375 0\n" project.session.createRecord("2021-02-01 19:00:00", "2021-02-01 19:30:00", 700, 375, 10),
"2021-02-01 19:00:00 2021-02-01 19:30:00 700 375 10\n" project.session.createRecord("2021-02-01 20:00:00", "2021-02-01 20:30:00", 600, 275, 20),
"2021-02-01 20:00:00 2021-02-01 20:30:00 600 275 20\n" project.session.createRecord("2021-02-02 19:00:00", "2021-02-02 19:30:00", 750, 425, 30),
"2021-02-02 19:00:00 2021-02-02 19:30:00 750 425 30\n" project.session.createRecord("2021-02-02 20:00:00", "2021-02-02 20:30:00", 690, 365, 40),
"2021-02-02 20:00:00 2021-02-02 20:30:00 690 365 40\n" project.session.createRecord("2021-02-03 19:00:00", "2021-02-03 19:30:00", 680, 355, 50),
"2021-02-03 19:00:00 2021-02-03 19:30:00 680 355 50\n" project.session.createRecord("2021-02-04 19:00:00", "2021-02-04 19:30:00", 700, 375, 60),
"2021-02-04 19:00:00 2021-02-04 19:30:00 700 375 60\n" project.session.createRecord("2021-02-05 19:00:00", "2021-02-05 19:30:00", 500, 175, 70),
"2021-02-05 19:00:00 2021-02-05 19:30:00 500 175 70\n" project.session.createRecord("2021-02-06 19:00:00", "2021-02-06 19:30:00", 600, 275, 80),
"2021-02-06 19:00:00 2021-02-06 19:30:00 600 275 80\n" project.session.createRecord("2021-02-07 19:00:00", "2021-02-07 19:30:00", 600, 275, 90),
"2021-02-07 19:00:00 2021-02-07 19:30:00 600 275 90\n" ]
)) sessFile.write_text("".join(data), encoding="utf-8")
sessLog.populateGUI() sessLog.populateGUI()
# Make the saving fail # Make the saving fail
+1 -2
View File
@@ -19,7 +19,6 @@ You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>. along with this program. If not, see <https://www.gnu.org/licenses/>.
""" """
import time
import shutil import shutil
from pathlib import Path from pathlib import Path
@@ -201,7 +200,7 @@ def buildTestProject(theObject, projPath):
aDoc.writeDocument("### %s\n\n" % theProject.tr("New Scene")) aDoc.writeDocument("### %s\n\n" % theProject.tr("New Scene"))
theProject.index.reIndexHandle(xHandle[8]) theProject.index.reIndexHandle(xHandle[8])
theProject._projOpened = time.time() theProject.session.startSession()
theProject.setProjectChanged(True) theProject.setProjectChanged(True)
theProject.saveProject(autoSave=True) theProject.saveProject(autoSave=True)