Rename and restructure project meta files (#1464)

This commit is contained in:
Veronica Berglyd Olsen
2023-06-15 23:13:14 +02:00
committed by GitHub
39 changed files with 1413 additions and 1006 deletions
+1 -2
View File
@@ -104,8 +104,7 @@ def checkBool(value: Any, default: bool) -> bool:
def checkHandle(value, default, allowNone=False):
"""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):
+4 -4
View File
@@ -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 -3
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+30 -28
View File
@@ -32,7 +32,7 @@ from typing import TYPE_CHECKING, Any
from pathlib import Path
from 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
View File
@@ -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
+138
View File
@@ -0,0 +1,138 @@
"""
novelWriter Project Session Log Class
=======================================
File History:
Created: 2023-06-11 [2.1b1]
This file is a part of novelWriter
Copyright 20182023, Veronica Berglyd Olsen
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful, but
WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
"""
from __future__ import annotations
import json
import logging
from time import time
from typing import TYPE_CHECKING, Iterator
from pathlib import Path
from novelwriter.error import logException
from novelwriter.common import formatTimeStamp
from novelwriter.constants import nwFiles
if TYPE_CHECKING: # pragma: no cover
from novelwriter.core.project import NWProject
logger = logging.getLogger(__name__)
class NWSessionLog:
"""Core: Session JSON Lines Log File
The class that wraps the session log file, which is in JSON Lines
format. That is, one JSON object per line.
"""
def __init__(self, project: NWProject):
self._project = project
self._start = 0.0
return
##
# Properties
##
@property
def start(self) -> float:
"""The session start time."""
return self._start
##
# Methods
##
def startSession(self):
"""Start the writng session."""
self._start = time()
return
def appendSession(self, idleTime: float) -> bool:
"""Append session statistics to the sessions log file."""
sessFile = self._project.storage.getMetaFile(nwFiles.SESS_FILE)
if not isinstance(sessFile, Path):
return False
now = time()
iNovel, iNotes = self._project.data.initCounts
cNovel, cNotes = self._project.data.currCounts
iTotal = iNovel + iNotes
wDiff = cNovel + cNotes - iTotal
sTime = now - self._start
logger.info("The session lasted %d sec and added %d words", int(sTime), wDiff)
if sTime < 300 and wDiff == 0:
logger.info("Session too short, skipping log entry")
return False
try:
if not sessFile.exists():
with open(sessFile, mode="w", encoding="utf-8") as fObj:
fObj.write(self.createInitial(iTotal))
with open(sessFile, mode="a+", encoding="utf-8") as fObj:
fObj.write(self.createRecord(
start=formatTimeStamp(self._start),
end=formatTimeStamp(now),
novel=cNovel,
notes=cNotes,
idle=round(idleTime)
))
except Exception:
logger.error("Failed to write to session stats file")
logException()
return False
return True
def iterRecords(self) -> Iterator[dict]:
"""Iterate through all records in the log."""
sessFile = self._project.storage.getMetaFile(nwFiles.SESS_FILE)
if isinstance(sessFile, Path) and sessFile.is_file():
try:
with open(sessFile, mode="r", encoding="utf-8") as fObj:
for line in fObj:
yield json.loads(line)
except Exception:
logger.error("Failed to process session stats file")
logException()
return
def createInitial(self, total: int) -> str:
"""Low level function to create the initial log file record."""
data = json.dumps({"type": "initial", "offset": total})
return f"{data}\n"
def createRecord(self, start: str, end: str, novel: int, notes: int, idle: int) -> str:
"""Low level function to create a log record."""
data = json.dumps({
"type": "record", "start": start, "end": end,
"novel": novel, "notes": notes, "idle": idle,
})
return f"{data}\n"
# END Class NWSessionLog
+124 -101
View File
@@ -1,7 +1,6 @@
"""
novelWriter Spell Check Classes
=================================
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
View File
@@ -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
+1 -1
View File
@@ -114,7 +114,7 @@ class GuiAbout(QDialog):
return
def __del__(self):
def __del__(self): # pragma: no cover
logger.debug("Delete: GuiAbout")
return
+1 -1
View File
@@ -113,7 +113,7 @@ class GuiDocMerge(QDialog):
return
def __del__(self):
def __del__(self): # pragma: no cover
logger.debug("Delete: GuiDocMerge")
return
+1 -1
View File
@@ -142,7 +142,7 @@ class GuiDocSplit(QDialog):
return
def __del__(self):
def __del__(self): # pragma: no cover
logger.debug("Delete: GuiDocSplit")
return
+1 -1
View File
@@ -87,7 +87,7 @@ class GuiPreferences(NPagedDialog):
return
def __del__(self):
def __del__(self): # pragma: no cover
logger.debug("Delete: GuiPreferences")
return
+1 -1
View File
@@ -82,7 +82,7 @@ class GuiProjectDetails(NPagedDialog):
return
def __del__(self):
def __del__(self): # pragma: no cover
logger.debug("Delete: GuiProjectDetails")
return
+1 -1
View File
@@ -151,7 +151,7 @@ class GuiProjectLoad(QDialog):
return
def __del__(self):
def __del__(self): # pragma: no cover
logger.debug("Delete: GuiProjectLoad")
return
+1 -1
View File
@@ -97,7 +97,7 @@ class GuiProjectSettings(NPagedDialog):
return
def __del__(self):
def __del__(self): # pragma: no cover
logger.debug("Delete: GuiProjectSettings")
return
+1 -1
View File
@@ -117,7 +117,7 @@ class GuiUpdates(QDialog):
return
def __del__(self):
def __del__(self): # pragma: no cover
logger.debug("Delete: GuiUpdates")
return
+38 -66
View File
@@ -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
View File
@@ -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>"
)
+5 -6
View File
@@ -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
View File
@@ -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)
+1 -1
View File
@@ -111,7 +111,7 @@ class GuiLipsum(QDialog):
return
def __del__(self):
def __del__(self): # pragma: no cover
logger.debug("Delete: GuiLipsum")
return
+3 -2
View File
@@ -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
+3 -2
View File
@@ -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
+7 -6
View File
@@ -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)
+1 -1
View File
@@ -80,7 +80,7 @@ class GuiProjectWizard(QWizard):
return
def __del__(self):
def __del__(self): # pragma: no cover
logger.debug("Delete: GuiProjectWizard")
return
+40 -58
View File
@@ -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
View File
@@ -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": ""}
+3 -3
View File
@@ -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
+13 -11
View File
@@ -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,
+2 -47
View File
@@ -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
+114
View File
@@ -0,0 +1,114 @@
"""
novelWriter NWSessionLog Class Tester
=======================================
This file is a part of novelWriter
Copyright 20182023, Veronica Berglyd Olsen
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful, but
WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
"""
import pytest
from time import sleep
from pathlib import Path
from tools import buildTestProject
from mocked import causeOSError
from novelwriter.constants import nwFiles
from novelwriter.core.project import NWProject
from novelwriter.core.sessions import NWSessionLog
@pytest.mark.core
def testCoreSessions_Main(monkeypatch, mockGUI, fncPath):
"""Test log file handling of the NWSessionLog class."""
project = NWProject(mockGUI)
buildTestProject(project, fncPath)
logFile = project.storage.getMetaFile(nwFiles.SESS_FILE)
assert isinstance(logFile, Path)
# Set some moch word counts
project.data.setInitCounts(50, 60)
project.data.setCurrCounts(160, 150)
# The project init should already have created the session
sessLog = project.session
assert isinstance(sessLog, NWSessionLog)
assert sessLog.start > 0.0
# Starting the session again should reset the timer
currTime = sessLog.start
sleep(0.015) # Make sure we don't hit clock resolution issues on Windows
sessLog.startSession()
assert sessLog.start > currTime
# There should not be a logfile
assert not logFile.exists()
assert len(list(sessLog.iterRecords())) == 0
# Create the initial and first records
assert sessLog.appendSession(0.8) is True
assert logFile.exists() # Logfile now exists
records = list(sessLog.iterRecords())
assert len(records) == 2
assert records[0]["type"] == "initial"
assert records[0]["offset"] == 110 # Sum of initial word counts
assert records[1]["type"] == "record"
assert records[1]["novel"] == 160
assert records[1]["notes"] == 150
assert records[1]["idle"] == 1 # Should be rounded to full seconds
# Adding another record without changing word count should do nothing
project.data.setInitCounts(160, 150)
project.data.setCurrCounts(160, 150)
assert sessLog.appendSession(1.6) is False
assert len(list(sessLog.iterRecords())) == 2
# But adding when count has changed should
project.data.setInitCounts(160, 150)
project.data.setCurrCounts(270, 240)
sessLog._start -= 350.0 # Backdate the session start to allow logging
assert sessLog.appendSession(1.6) is True
records = list(sessLog.iterRecords())
assert len(records) == 3
assert records[2]["novel"] == 270
assert records[2]["notes"] == 240
assert records[2]["idle"] == 2 # Should be rounded to full seconds
# Make file path unresolvable, and try appending another record
with monkeypatch.context() as mp:
mp.setattr("novelwriter.core.storage.NWStorage.getMetaFile", lambda *a: None)
assert sessLog.appendSession(1.6) is False
assert len(list(sessLog.iterRecords())) == 3
# Make the file open fail, and check that it's handled
with monkeypatch.context() as mp:
mp.setattr("builtins.open", causeOSError)
assert sessLog.appendSession(1.6) is False
assert len(list(sessLog.iterRecords())) == 3
# Make the file load fail, and check that it's handled
with monkeypatch.context() as mp:
mp.setattr("builtins.open", causeOSError)
assert len(list(sessLog.iterRecords())) == 0
# Make file path unresolvable
with monkeypatch.context() as mp:
mp.setattr("novelwriter.core.storage.NWStorage.getMetaFile", lambda *a: None)
assert len(list(sessLog.iterRecords())) == 0
# END Test testCoreSessions_Main
+133 -98
View File
@@ -21,34 +21,119 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
import sys
import 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
+183 -40
View File
@@ -19,22 +19,24 @@ You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
"""
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
+29 -31
View File
@@ -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()
+35 -26
View File
@@ -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()
+29 -40
View File
@@ -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
View File
@@ -19,7 +19,6 @@ You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
"""
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)