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