diff --git a/novelwriter/common.py b/novelwriter/common.py index 739c9e53..46a18c85 100644 --- a/novelwriter/common.py +++ b/novelwriter/common.py @@ -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): diff --git a/novelwriter/constants.py b/novelwriter/constants.py index dba2d2d0..2875a72b 100644 --- a/novelwriter/constants.py +++ b/novelwriter/constants.py @@ -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 diff --git a/novelwriter/core/coretools.py b/novelwriter/core/coretools.py index 627e6f75..d669586b 100644 --- a/novelwriter/core/coretools.py +++ b/novelwriter/core/coretools.py @@ -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 . 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) diff --git a/novelwriter/core/index.py b/novelwriter/core/index.py index 71ff9814..d16d4015 100644 --- a/novelwriter/core/index.py +++ b/novelwriter/core/index.py @@ -1,7 +1,6 @@ """ novelWriter – Project Index =========================== -Data class for the project index of tags, headers and references File History: Created: 2019-04-22 [0.0.1] countWords @@ -27,25 +26,33 @@ General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see . """ +from __future__ import annotations import json import logging from time import time +from typing import TYPE_CHECKING, ItemsView, Iterable, Iterator from pathlib import Path -from novelwriter.enum import nwItemType, nwItemLayout +from novelwriter.enum import nwItemClass, nwItemType, nwItemLayout from novelwriter.error import logException from novelwriter.common import checkInt, isHandle, isItemClass, isTitleTag, jsonEncode from novelwriter.constants import nwFiles, nwKeyWords, nwUnicode, nwHeaders +if TYPE_CHECKING: # pragma: no cover + from novelwriter.core.item import NWItem + from novelwriter.core.project import NWProject + logger = logging.getLogger(__name__) TT_NONE = "T0000" class NWIndex: - """This class holds the entire index for a given project. The index + """Core: Project Index + + This class holds the entire index for a given project. The index contains the data that isn't stored in the project items themselves. The content of the index is updated every time a file item is saved. @@ -100,8 +107,7 @@ class NWIndex: ## def clearIndex(self): - """Clear the index dictionaries and time stamps. - """ + """Clear the index dictionaries and time stamps.""" self._tagsIndex.clear() self._itemIndex.clear() self._indexChange = 0.0 @@ -109,8 +115,7 @@ class NWIndex: return def rebuildIndex(self): - """Rebuild the entire index from scratch. - """ + """Rebuild the entire index from scratch.""" self.clearIndex() for nwItem in self._project.tree: if nwItem.isFileType(): @@ -120,9 +125,8 @@ class NWIndex: self._indexBroken = False return - def deleteHandle(self, tHandle): - """Delete all entries of a given document handle. - """ + def deleteHandle(self, tHandle: str): + """Delete all entries of a given document handle.""" logger.debug("Removing item '%s' from the index", tHandle) for tTag in self._itemIndex.allItemTags(tHandle): del self._tagsIndex[tTag] @@ -131,7 +135,7 @@ class NWIndex: return - def reIndexHandle(self, tHandle): + def reIndexHandle(self, tHandle: str) -> bool: """Put a file back into the index. This is used when files are moved from the archive or trash folders back into the active project. @@ -145,12 +149,11 @@ class NWIndex: return True - def indexChangedSince(self, checkTime): - """Check if the index has changed since a given time. - """ + def indexChangedSince(self, checkTime: int | float) -> bool: + """Check if the index has changed since a given time.""" return self._indexChange > float(checkTime) - def rootChangedSince(self, rootHandle, checkTime): + def rootChangedSince(self, rootHandle: str, checkTime: int | float) -> bool: """Check if the index has changed since a given time for a given root item. """ @@ -183,8 +186,8 @@ class NWIndex: return False try: - self._tagsIndex.unpackData(theData["tagsIndex"]) - self._itemIndex.unpackData(theData["itemIndex"]) + self._tagsIndex.unpackData(theData["novelWriter.tagsIndex"]) + self._itemIndex.unpackData(theData["novelWriter.itemIndex"]) except Exception: logger.error("The index content is invalid") logException() @@ -205,7 +208,7 @@ class NWIndex: return True - def saveIndex(self): + def saveIndex(self) -> bool: """Save the current index as a json file in the project meta data folder. """ @@ -217,12 +220,12 @@ class NWIndex: tStart = time() try: - tagsIndex = self._tagsIndex.packData() - itemIndex = self._itemIndex.packData() + tagsIndex = jsonEncode(self._tagsIndex.packData(), n=1, nmax=2) + itemIndex = jsonEncode(self._itemIndex.packData(), n=1, nmax=4) with open(indexFile, mode="w+", encoding="utf-8") as outFile: outFile.write("{\n") - outFile.write(f' "tagsIndex": {jsonEncode(tagsIndex, n=1, nmax=2)},\n') - outFile.write(f' "itemIndex": {jsonEncode(itemIndex, n=1, nmax=4)}\n') + outFile.write(f' "novelWriter.tagsIndex": {tagsIndex},\n') + outFile.write(f' "novelWriter.itemIndex": {itemIndex}\n') outFile.write("}\n") except Exception: @@ -238,7 +241,7 @@ class NWIndex: # Index Building ## - def scanText(self, tHandle, theText): + def scanText(self, tHandle: str, theText: str) -> bool: """Scan a piece of text associated with a handle. This will update the indices accordingly. This function takes the handle and text as separate inputs as we want to primarily scan the @@ -289,15 +292,14 @@ class NWIndex: # Internal Indexer Helpers ## - def _scanActive(self, tHandle, theItem, theText, itemTags): - """Scan an active document for meta data. - """ + def _scanActive(self, tHandle: str, nwItem: NWItem, text: str, tags: dict): + """Scan an active document for meta data.""" nTitle = 0 # Line Number of the previous title cTitle = TT_NONE # Tag of the current title pTitle = TT_NONE # Tag of the previous title canSetHeader = True # First header has not yet been set - theLines = theText.splitlines() + theLines = text.splitlines() for nLine, aLine in enumerate(theLines, start=1): if aLine.strip() == "": @@ -309,7 +311,7 @@ class NWIndex: continue if canSetHeader: - theItem.setMainHeading(hDepth) + nwItem.setMainHeading(hDepth) canSetHeader = False cTitle = self._itemIndex.addItemHeading(tHandle, nLine, hDepth, hText) @@ -323,7 +325,7 @@ class NWIndex: elif aLine.startswith("@"): if cTitle != TT_NONE: - self._indexKeyword(tHandle, aLine, cTitle, theItem.itemClass, itemTags) + self._indexKeyword(tHandle, aLine, cTitle, nwItem.itemClass, tags) elif aLine.startswith("%"): if cTitle != TT_NONE: @@ -343,58 +345,57 @@ class NWIndex: # Also count words on a page with no titles if cTitle == TT_NONE: - self._indexWordCounts(tHandle, theText, cTitle) + self._indexWordCounts(tHandle, text, cTitle) # Prune no longer used tags - for tTag, isActive in itemTags.items(): + for tTag, isActive in tags.items(): if not isActive: logger.debug("Deleting removed tag '%s'", tTag) del self._tagsIndex[tTag] return - def _scanInactive(self, theItem, theText): - """Scan an inactive document for meta data. - """ - for aLine in theText.splitlines(): + def _scanInactive(self, nwItem: NWItem, text: str): + """Scan an inactive document for meta data.""" + for aLine in text.splitlines(): if aLine.startswith("#"): hDepth, _ = self._splitHeading(aLine) if hDepth != "H0": - theItem.setMainHeading(hDepth) + nwItem.setMainHeading(hDepth) break return - def _splitHeading(self, aLine): - """Split a heading into its header level and text value. - """ - if aLine.startswith("# "): - return "H1", aLine[2:].strip() - elif aLine.startswith("## "): - return "H2", aLine[3:].strip() - elif aLine.startswith("### "): - return "H3", aLine[4:].strip() - elif aLine.startswith("#### "): - return "H4", aLine[5:].strip() - elif aLine.startswith("#! "): - return "H1", aLine[3:].strip() - elif aLine.startswith("##! "): - return "H2", aLine[4:].strip() + def _splitHeading(self, line: str) -> tuple[str, str]: + """Split a heading into its header level and text value.""" + if line.startswith("# "): + return "H1", line[2:].strip() + elif line.startswith("## "): + return "H2", line[3:].strip() + elif line.startswith("### "): + return "H3", line[4:].strip() + elif line.startswith("#### "): + return "H4", line[5:].strip() + elif line.startswith("#! "): + return "H1", line[3:].strip() + elif line.startswith("##! "): + return "H2", line[4:].strip() return "H0", "" - def _indexWordCounts(self, tHandle, theText, sTitle): - """Count text stats and save the counts to the index. - """ - cC, wC, pC = countWords(theText) + def _indexWordCounts(self, tHandle: str, text: str, sTitle: str): + """Count text stats and save the counts to the index.""" + cC, wC, pC = countWords(text) self._itemIndex.setHeadingCounts(tHandle, sTitle, cC, wC, pC) return - def _indexKeyword(self, tHandle, aLine, sTitle, itemClass, itemTags): + def _indexKeyword( + self, tHandle: str, line: str, sTitle: str, itemClass: nwItemClass, tags: dict + ): """Validate and save the information about a reference to a tag in another file, or the setting of a tag in the file. A record of active tags is updated so that no longer used tags can be pruned later. """ - isValid, theBits, _ = self.scanThis(aLine) + isValid, theBits, _ = self.scanThis(line) if not isValid or len(theBits) < 2: logger.warning("Skipping keyword with %d value(s) in '%s'", len(theBits), tHandle) return @@ -407,7 +408,7 @@ class NWIndex: tagName = theBits[1] self._tagsIndex.add(tagName, tHandle, sTitle, itemClass) self._itemIndex.setHeadingTag(tHandle, sTitle, tagName) - itemTags[tagName] = True + tags[tagName] = True else: self._itemIndex.addHeadingReferences(tHandle, sTitle, theBits[1:], theBits[0]) @@ -417,71 +418,71 @@ class NWIndex: # Check @ Lines ## - def scanThis(self, aLine): + def scanThis(self, line: str) -> tuple[bool, list[str], list[int]]: """Scan a line starting with @ to check that it's valid. Then split it up into its elements and positions as two arrays. """ - theBits = [] # The elements of the string - thePos = [] # The absolute position of each element + tBits = [] # The elements of the string + tPos = [] # The absolute position of each element - aLine = aLine.rstrip() # Remove all trailing white spaces - nChar = len(aLine) + line = line.rstrip() # Remove all trailing white spaces + nChar = len(line) if nChar < 2: - return False, theBits, thePos - if aLine[0] != "@": - return False, theBits, thePos + return False, tBits, tPos + if line[0] != "@": + return False, tBits, tPos - cKey, _, cVals = aLine.partition(":") + cKey, _, cVals = line.partition(":") sKey = cKey.strip() if sKey == "@": - return False, theBits, thePos + return False, tBits, tPos cPos = 0 - theBits.append(sKey) - thePos.append(cPos) + tBits.append(sKey) + tPos.append(cPos) cPos += len(cKey) + 1 if not cVals: # No values, so we're done - return True, theBits, thePos + return True, tBits, tPos for cVal in cVals.split(","): sVal = cVal.strip() rLen = len(cVal.lstrip()) tLen = len(cVal) - theBits.append(sVal) - thePos.append(cPos + tLen - rLen) + tBits.append(sVal) + tPos.append(cPos + tLen - rLen) cPos += tLen + 1 - return True, theBits, thePos + return True, tBits, tPos - def checkThese(self, theBits, tItem): + def checkThese(self, tBits: list[str], nwItem: NWItem) -> list[bool]: """Check the tags against the index to see if they are valid tags. This is needed for syntax highlighting. """ - nBits = len(theBits) + nBits = len(tBits) isGood = [False]*nBits if nBits == 0: return [] # Check that the key is valid - isGood[0] = theBits[0] in nwKeyWords.VALID_KEYS + isGood[0] = tBits[0] in nwKeyWords.VALID_KEYS if not isGood[0] or nBits == 1: return isGood # For a tag, only the first value is accepted, the rest are ignored - if theBits[0] == nwKeyWords.TAG_KEY and nBits > 1: - if theBits[1] in self._tagsIndex: - isGood[1] = self._tagsIndex.tagHandle(theBits[1]) == tItem.itemHandle + if tBits[0] == nwKeyWords.TAG_KEY and nBits > 1: + if tBits[1] in self._tagsIndex: + isGood[1] = self._tagsIndex.tagHandle(tBits[1]) == nwItem.itemHandle else: isGood[1] = True return isGood # If we're still here, we check that the references exist - theKey = nwKeyWords.KEY_CLASS[theBits[0]].name + theKey = nwKeyWords.KEY_CLASS[tBits[0]].name for n in range(1, nBits): - if theBits[n] in self._tagsIndex: - isGood[n] = self._tagsIndex.tagClass(theBits[n]) == theKey + if tBits[n] in self._tagsIndex: + isGood[n] = self._tagsIndex.tagClass(tBits[n]) == theKey return isGood @@ -489,62 +490,60 @@ class NWIndex: # Extract Data ## - def getItemData(self, tHandle): - """Get the index data for a given item. - """ + def getItemData(self, tHandle: str) -> IndexItem | None: + """Get the index data for a given item.""" return self._itemIndex[tHandle] - def getItemHeader(self, tHandle, sTitle): - """Get the header entry for a specific item and heading. - """ + def getItemHeader(self, tHandle: str, sTitle: str) -> IndexHeading | None: + """Get the header entry for a specific item and heading.""" tItem = self._itemIndex[tHandle] if isinstance(tItem, IndexItem): return tItem[sTitle] return None - def novelStructure(self, rootHandle=None, skipExcl=True): + def novelStructure( + self, rootHandle: str | None = None, skipExcl: bool = True + ) -> Iterator[tuple[str, str, str, IndexHeading]]: """Iterate over all titles in the novel, in the correct order as they appear in the tree view and in the respective document files, but skipping all note files. """ - novStruct = self._itemIndex.iterNovelStructure(rootHandle=rootHandle, skipExcl=skipExcl) + novStruct = self._itemIndex.iterNovelStructure(rHandle=rootHandle, skipExcl=skipExcl) for tHandle, sTitle, hItem in novStruct: yield f"{tHandle}:{sTitle}", tHandle, sTitle, hItem return - def getNovelWordCount(self, skipExcl=True): - """Count the number of words in the novel project. - """ + def getNovelWordCount(self, skipExcl: bool = True) -> int: + """Count the number of words in the novel project.""" wCount = 0 for _, _, hItem in self._itemIndex.iterNovelStructure(skipExcl=skipExcl): wCount += hItem.wordCount return wCount - def getNovelTitleCounts(self, skipExcl=True): - """Count the number of titles in the novel project. - """ + def getNovelTitleCounts(self, skipExcl: bool = True) -> list[int]: + """Count the number of titles in the novel project.""" hCount = [0, 0, 0, 0, 0] for _, _, hItem in self._itemIndex.iterNovelStructure(skipExcl=skipExcl): iLevel = nwHeaders.H_LEVEL.get(hItem.level, 0) hCount[iLevel] += 1 return hCount - def getHandleHeaderCount(self, tHandle): - """Get the number of headers in an item. - """ + def getHandleHeaderCount(self, tHandle: str) -> int: + """Get the number of headers in an item.""" tItem = self._itemIndex[tHandle] if isinstance(tItem, IndexItem): return len(tItem) return 0 - def getTableOfContents(self, rootHandle, maxDepth, skipExcl=True): - """Generate a table of contents up to a maximum depth. - """ + def getTableOfContents( + self, rHandle: str, maxDepth: int, skipExcl: bool = True + ) -> list[tuple[str, str, str, int]]: + """Generate a table of contents up to a maximum depth.""" tOrder = [] tData = {} pKey = None for tHandle, sTitle, hItem in self._itemIndex.iterNovelStructure( - rootHandle=rootHandle, skipExcl=skipExcl + rHandle=rHandle, skipExcl=skipExcl ): tKey = f"{tHandle}:{sTitle}" iLevel = nwHeaders.H_LEVEL.get(hItem.level, 0) @@ -569,7 +568,7 @@ class NWIndex: return theToC - def getCounts(self, tHandle, sTitle=None): + def getCounts(self, tHandle: str, sTitle: str | None = None) -> tuple[int, int, int]: """Return the counts for a file, or a section of a file, starting at title sTitle if it is provided. """ @@ -587,7 +586,7 @@ class NWIndex: return 0, 0, 0 - def getReferences(self, tHandle, sTitle=None): + def getReferences(self, tHandle: str, sTitle: str | None = None) -> dict[str, list[str]]: """Extract all references made in a file, and optionally title section. """ @@ -601,7 +600,7 @@ class NWIndex: return theRefs - def getBackReferenceList(self, tHandle): + def getBackReferenceList(self, tHandle: str) -> dict[str, str]: """Build a list of files referring back to our file, specified by tHandle. """ @@ -620,11 +619,10 @@ class NWIndex: return theRefs - def getTagSource(self, theTag): - """Return the source location of a given tag. - """ - tHandle = self._tagsIndex.tagHandle(theTag) - sTitle = self._tagsIndex.tagHeading(theTag) + def getTagSource(self, tagKey: str) -> tuple[str, str]: + """Return the source location of a given tag.""" + tHandle = self._tagsIndex.tagHandle(tagKey) + sTitle = self._tagsIndex.tagHeading(tagKey) return tHandle, sTitle # END Class NWIndex @@ -635,7 +633,9 @@ class NWIndex: # =============================================================================================== # class TagsIndex: - """A wrapper class that holds the reverse lookup tags index. This is + """Core: Tags Index Wrapper Class + + A wrapper class that holds the reverse lookup tags index. This is just a simple wrapper around a single dictionary to keep tighter control of the keys. """ @@ -643,7 +643,7 @@ class TagsIndex: __slots__ = ("_tags") def __init__(self): - self._tags = {} + self._tags: dict[str, dict] = {} return def __contains__(self, tagKey): @@ -661,44 +661,38 @@ class TagsIndex: ## def clear(self): - """Clear the index. - """ + """Clear the index.""" self._tags = {} return - def add(self, tagKey, tHandle, sTitle, itemClass): - """Add a key to the index and set all values. - """ + def add(self, tagKey: str, tHandle: str, sTitle: str, itemClass: nwItemClass): + """Add a key to the index and set all values.""" self._tags[tagKey] = { "handle": tHandle, "heading": sTitle, "class": itemClass.name } return - def tagHandle(self, tagKey): - """Get the handle of a given tag. - """ + def tagHandle(self, tagKey: str) -> str: + """Get the handle of a given tag.""" return self._tags.get(tagKey, {}).get("handle", None) - def tagHeading(self, tagKey): - """Get the heading of a given tag. - """ + def tagHeading(self, tagKey: str) -> str: + """Get the heading of a given tag.""" return self._tags.get(tagKey, {}).get("heading", TT_NONE) - def tagClass(self, tagKey): - """Get the class of a given tag. - """ + def tagClass(self, tagKey: str) -> str | None: + """Get the class of a given tag.""" return self._tags.get(tagKey, {}).get("class", None) ## # Pack/Unpack ## - def packData(self): - """Pack all the data of the tags into a single dictionary. - """ + def packData(self) -> dict: + """Pack all the data of the tags into a single dictionary.""" return self._tags - def unpackData(self, data): + def unpackData(self, data: dict): """Iterate through the tagsIndex loaded from cache and check that it's valid. """ @@ -734,7 +728,9 @@ class TagsIndex: # =============================================================================================== # class ItemIndex: - """A wrapper object holding the indexed items. This is a warapper + """Core: Item Index Wrapper Class + + A wrapper object holding the indexed items. This is a warapper class around a single storage dictionary with a set of utility functions for setting and accessing the index data. Each indexed item is stored in an IndexItem object, which again holds an @@ -743,19 +739,19 @@ class ItemIndex: __slots__ = ("_project", "_items") - def __init__(self, project): + def __init__(self, project: NWProject): self._project = project - self._items = {} + self._items: dict[str, IndexItem] = {} return - def __contains__(self, tHandle): + def __contains__(self, tHandle: str) -> bool: return tHandle in self._items - def __delitem__(self, tHandle): + def __delitem__(self, tHandle: str): self._items.pop(tHandle, None) return - def __getitem__(self, tHandle): + def __getitem__(self, tHandle: str) -> IndexItem | None: return self._items.get(tHandle, None) ## @@ -763,41 +759,39 @@ class ItemIndex: ## def clear(self): - """Clear the index. - """ + """Clear the index.""" self._items = {} return - def add(self, tHandle, tItem): + def add(self, tHandle: str, nwItem: NWItem): """Add a new item to the index. This will overwrite the item if it already exists. """ - self._items[tHandle] = IndexItem(tHandle, tItem) + self._items[tHandle] = IndexItem(tHandle, nwItem) return - def allItemTags(self, tHandle): - """Get all tags set for headings of an item. - """ + def allItemTags(self, tHandle: str) -> list[str]: + """Get all tags set for headings of an item.""" if tHandle in self._items: return self._items[tHandle].allTags() return [] - def iterItemHeaders(self, tHandle): - """Iterate over all item headers of an item. - """ + def iterItemHeaders(self, tHandle: str) -> Iterable[tuple[str, IndexHeading]]: + """Iterate over all item headers of an item.""" if tHandle in self._items: yield from self._items[tHandle].items() return - def iterAllHeaders(self): - """Iterate through all items and headings in the index. - """ + def iterAllHeaders(self) -> Iterable[tuple[str, str, IndexHeading]]: + """Iterate through all items and headings in the index.""" for tHandle, tItem in self._items.items(): for sTitle, hItem in tItem.items(): yield tHandle, sTitle, hItem return - def iterNovelStructure(self, rootHandle=None, skipExcl=False): + def iterNovelStructure( + self, rHandle: str | None = None, skipExcl: bool = False + ) -> Iterable[tuple[str, str, IndexHeading]]: """Iterate over all items and headers in the novel structure for a given root handle, or for all if root handle is None. """ @@ -808,15 +802,19 @@ class ItemIndex: continue tHandle = tItem.itemHandle - if tHandle not in self._items: + if tHandle is None or tHandle not in self._items: continue - if rootHandle is None: + if rHandle is None: for sTitle in self._items[tHandle].headings(): - yield tHandle, sTitle, self._items[tHandle][sTitle] - elif tItem.itemRoot == rootHandle: + hItem = self._items[tHandle][sTitle] + if hItem: + yield tHandle, sTitle, hItem + elif tItem.itemRoot == rHandle: for sTitle in self._items[tHandle].headings(): - yield tHandle, sTitle, self._items[tHandle][sTitle] + hItem = self._items[tHandle][sTitle] + if hItem: + yield tHandle, sTitle, hItem return @@ -824,17 +822,16 @@ class ItemIndex: # Setters ## - def addItemHeading(self, tHandle, lineNo, hDepth, hText): - """Add a heading to an item. - """ + def addItemHeading(self, tHandle: str, lineNo: int, level: str, text: str) -> str: + """Add a heading to an item.""" if tHandle in self._items: tItem = self._items[tHandle] sTitle = tItem.nextHeading() - tItem.addHeading(IndexHeading(sTitle, lineNo, hDepth, hText)) + tItem.addHeading(IndexHeading(sTitle, lineNo, level, text)) return sTitle return TT_NONE - def setHeadingCounts(self, tHandle, sTitle, cC, wC, pC): + def setHeadingCounts(self, tHandle: str, sTitle: str, cC: int, wC: int, pC: int): """Set the character, word and paragraph counts of a heading on a given item. """ @@ -842,23 +839,20 @@ class ItemIndex: self._items[tHandle].setHeadingCounts(sTitle, cC, wC, pC) return - def setHeadingSynopsis(self, tHandle, sTitle, sText): - """Set the synopsis text for a heading on a given item. - """ + def setHeadingSynopsis(self, tHandle: str, sTitle: str, text: str): + """Set the synopsis text for a heading on a given item.""" if tHandle in self._items: - self._items[tHandle].setHeadingSynopsis(sTitle, sText) + self._items[tHandle].setHeadingSynopsis(sTitle, text) return - def setHeadingTag(self, tHandle, sTitle, tagKey): - """Set the main tag for a heading on a given item. - """ + def setHeadingTag(self, tHandle: str, sTitle: str, tagKey: str): + """Set the main tag for a heading on a given item.""" if tHandle in self._items: self._items[tHandle].setHeadingTag(sTitle, tagKey) return - def addHeadingReferences(self, tHandle, sTitle, tagKeys, refType): - """Set the reference tags for a heading on a given item. - """ + def addHeadingReferences(self, tHandle: str, sTitle: str, tagKeys: list[str], refType: str): + """Set the reference tags for a heading on a given item.""" if tHandle in self._items: self._items[tHandle].addHeadingReferences(sTitle, tagKeys, refType) return @@ -867,12 +861,11 @@ class ItemIndex: # Pack/Unpack ## - def packData(self): - """Pack all the data of the index into a single dictionary. - """ + def packData(self) -> dict: + """Pack all the data of the index into a single dictionary.""" return {handle: item.packData() for handle, item in self._items.items()} - def unpackData(self, data): + def unpackData(self, data: dict): """Iterate through the itemIndex loaded from cache and check that it's valid. This will raise errors if there is a problem. """ @@ -896,7 +889,9 @@ class ItemIndex: class IndexItem: - """This object represents the index data of a project item (NWItem). + """Core: Single Index Item Class + + This object represents the index data of a project item (NWItem). It holds a record of all the headings in the text, and the meta data associated with each heading. It also holds a pointer to the project item. The main heading level of the item is also held here since it @@ -905,10 +900,10 @@ class IndexItem: __slots__ = ("_handle", "_item", "_headings", "_headings", "_count") - def __init__(self, tHandle, tItem): + def __init__(self, tHandle: str, nwItem: NWItem): self._handle = tHandle - self._item = tItem - self._headings = {} + self._item = nwItem + self._headings: dict[str, IndexHeading] = {} self._count = 0 # Add a placeholder heading @@ -916,16 +911,16 @@ class IndexItem: return - def __repr__(self): + def __repr__(self) -> str: return f"" - def __len__(self): + def __len__(self) -> int: return len(self._headings) - def __getitem__(self, sTitle): + def __getitem__(self, sTitle: str) -> IndexHeading | None: return self._headings.get(sTitle, None) - def __contains__(self, sTitle): + def __contains__(self, sTitle: str) -> bool: return sTitle in self._headings ## @@ -933,14 +928,14 @@ class IndexItem: ## @property - def item(self): + def item(self) -> NWItem: return self._item ## # Setters ## - def addHeading(self, tHeading): + def addHeading(self, tHeading: IndexHeading): """Add a heading to the item. Also remove the placeholder entry if it exists. """ @@ -949,30 +944,26 @@ class IndexItem: self._headings[tHeading.key] = tHeading return - def setHeadingCounts(self, sTitle, charCount, wordCount, paraCount): - """Set the character, word and paragraph count of a heading. - """ + def setHeadingCounts(self, sTitle: str, cCount: int, wCount: int, pCount: int): + """Set the character, word and paragraph count of a heading.""" if sTitle in self._headings: - self._headings[sTitle].setCounts(charCount, wordCount, paraCount) + self._headings[sTitle].setCounts(cCount, wCount, pCount) return - def setHeadingSynopsis(self, sTitle, synopText): - """Set the synopsis text of a heading. - """ + def setHeadingSynopsis(self, sTitle: str, text: str): + """Set the synopsis text of a heading.""" if sTitle in self._headings: - self._headings[sTitle].setSynopsis(synopText) + self._headings[sTitle].setSynopsis(text) return - def setHeadingTag(self, sTitle, tagKey): - """Set the tag of a heading. - """ + def setHeadingTag(self, sTitle: str, tagKey: str): + """Set the tag of a heading.""" if sTitle in self._headings: self._headings[sTitle].setTag(tagKey) return - def addHeadingReferences(self, sTitle, tagKeys, refType): - """Add a reference key and all its types to a heading. - """ + def addHeadingReferences(self, sTitle: str, tagKeys: list[str], refType: str): + """Add a reference key and all its types to a heading.""" if sTitle in self._headings: for tagKey in tagKeys: self._headings[sTitle].addReference(tagKey, refType) @@ -982,25 +973,18 @@ class IndexItem: # Data Methods ## - def items(self): + def items(self) -> ItemsView[str, IndexHeading]: return self._headings.items() - def headings(self): + def headings(self) -> list[str]: return sorted(self._headings.keys()) - def allTags(self): - """Return a list of all tags in the current item. - """ - tags = [] - for hItem in self._headings.values(): - tag = hItem.tag - if tag: - tags.append(tag) - return tags + def allTags(self) -> list[str]: + """Return a list of all tags in the current item.""" + return [h.tag for h in self._headings.values() if h.tag] - def nextHeading(self): - """Return the next heading key to be used. - """ + def nextHeading(self) -> str: + """Return the next heading key to be used.""" self._count += 1 return f"T{self._count:04d}" @@ -1008,9 +992,8 @@ class IndexItem: # Pack/Unpack ## - def packData(self): - """Pack the indexed item's data into a dictionary. - """ + def packData(self) -> dict: + """Pack the indexed item's data into a dictionary.""" heads = {} refs = {} for sTitle, hItem in self._headings.items(): @@ -1026,9 +1009,8 @@ class IndexItem: return data - def unpackData(self, data): - """Unpack an item entry from the data. - """ + def unpackData(self, data: dict): + """Unpack an item entry from the data.""" references = data.get("references", {}) for sTitle, hData in data.get("headings", {}).items(): if not isTitleTag(sTitle): @@ -1037,16 +1019,17 @@ class IndexItem: tHeading.unpackData(hData) tHeading.unpackReferences(references.get(sTitle, {})) self.addHeading(tHeading) - return # END Class IndexItem class IndexHeading: - """This object represents a section of text in a project item + """Core: Single Index Heading Class + + This object represents a section of text in a project item associated with a single (valid) heading. It holds a separate record - of all references made under each heading. + of all references made under the heading. """ __slots__ = ( @@ -1054,7 +1037,7 @@ class IndexHeading: "_paraCount", "_synopsis", "_tag", "_refs", ) - def __init__(self, key, line=0, level="H0", title=""): + def __init__(self, key: str, line: int = 0, level: str = "H0", title: str = ""): self._key = key self._line = line self._level = level @@ -1066,11 +1049,11 @@ class IndexHeading: self._synopsis = "" self._tag = "" - self._refs = {} + self._refs: dict[str, set[str]] = {} return - def __repr__(self): + def __repr__(self) -> str: return f"" ## @@ -1078,63 +1061,61 @@ class IndexHeading: ## @property - def key(self): + def key(self) -> str: return self._key @property - def line(self): + def line(self) -> int: return self._line @property - def level(self): + def level(self) -> str: return self._level @property - def title(self): + def title(self) -> str: return self._title @property - def charCount(self): + def charCount(self) -> int: return self._charCount @property - def wordCount(self): + def wordCount(self) -> int: return self._wordCount @property - def paraCount(self): + def paraCount(self) -> int: return self._paraCount @property - def synopsis(self): + def synopsis(self) -> str: return self._synopsis @property - def tag(self): + def tag(self) -> str: return self._tag @property - def references(self): + def references(self) -> dict: return self._refs ## # Setters ## - def setLevel(self, level): - """Set the level of the header if it's a valid value. - """ + def setLevel(self, level: str): + """Set the level of the header if it's a valid value.""" if level in nwHeaders.H_VALID: self._level = level return - def setLine(self, line): - """Set the line number of a heading. - """ + def setLine(self, line: int): + """Set the line number of a heading.""" self._line = max(0, checkInt(line, 0)) return - def setCounts(self, charCount, wordCount, paraCount): + def setCounts(self, charCount: int, wordCount: int, paraCount: int): """Set the character, word and paragraph count. Make sure the value is an integer and is not smaller than 0. """ @@ -1143,19 +1124,17 @@ class IndexHeading: self._paraCount = max(0, checkInt(paraCount, 0)) return - def setSynopsis(self, synopText): - """Set the synopsis text and make sure it is a string. - """ - self._synopsis = str(synopText) + def setSynopsis(self, text: str): + """Set the synopsis text and make sure it is a string.""" + self._synopsis = str(text) return - def setTag(self, tagKey): - """Set the tag for references, and make sure it is a string. - """ + def setTag(self, tagKey: str): + """Set the tag for references, and make sure it is a string.""" self._tag = str(tagKey) return - def addReference(self, tagKey, refType): + def addReference(self, tagKey: str, refType: str): """Add a record of a reference tag, and what keyword types it is associated with. """ @@ -1169,9 +1148,8 @@ class IndexHeading: # Data Methods ## - def packData(self): - """Pack the values into a dictionary for saving to cache. - """ + def packData(self) -> dict: + """Pack the values into a dictionary for saving to cache.""" return { "level": self._level, "title": self._title, @@ -1183,7 +1161,7 @@ class IndexHeading: "synopsis": self._synopsis, } - def packReferences(self): + def packReferences(self) -> dict[str, str]: """Pack references into a dictionary for saving to cache. Multiple types are packed into a sorted, comma separated string. It is sorted to prevent creating unnecessary diffs as the order @@ -1191,9 +1169,8 @@ class IndexHeading: """ return {key: ",".join(sorted(list(value))) for key, value in self._refs.items()} - def unpackData(self, data): - """Unpack a heading entry from a dictionary. - """ + def unpackData(self, data: dict): + """Unpack a heading entry from a dictionary.""" self.setLevel(data.get("level", "H0")) self._title = str(data.get("title", "")) self._tag = str(data.get("tag", "")) @@ -1206,9 +1183,8 @@ class IndexHeading: self._synopsis = str(data.get("synopsis", "")) return - def unpackReferences(self, data): - """Unpack a set of references from a dictionary. - """ + def unpackReferences(self, data: dict): + """Unpack a set of references from a dictionary.""" for tagKey, refTypes in data.items(): if not isinstance(tagKey, str): raise ValueError("itemIndex reference key must be a string") @@ -1228,7 +1204,7 @@ class IndexHeading: # Simple Word Counter # =============================================================================================== # -def countWords(theText): +def countWords(text: str) -> tuple[int, int, int]: """Count words in a piece of text, skipping special syntax and comments. """ @@ -1237,25 +1213,26 @@ def countWords(theText): paraCount = 0 prevEmpty = True - if not isinstance(theText, str): + if not isinstance(text, str): return charCount, wordCount, paraCount # We need to treat dashes as word separators for counting words. # The check+replace approach is much faster than direct replace for # large texts, and a bit slower for small texts, but in the latter # case it doesn't really matter. - if nwUnicode.U_ENDASH in theText: - theText = theText.replace(nwUnicode.U_ENDASH, " ") - if nwUnicode.U_EMDASH in theText: - theText = theText.replace(nwUnicode.U_EMDASH, " ") + if nwUnicode.U_ENDASH in text: + text = text.replace(nwUnicode.U_ENDASH, " ") + if nwUnicode.U_EMDASH in text: + text = text.replace(nwUnicode.U_EMDASH, " ") - for aLine in theText.splitlines(): + for aLine in text.splitlines(): countPara = True if not aLine: prevEmpty = True continue + if aLine[0] == "@" or aLine[0] == "%": continue diff --git a/novelwriter/core/options.py b/novelwriter/core/options.py index 9a1c0af8..9409f743 100644 --- a/novelwriter/core/options.py +++ b/novelwriter/core/options.py @@ -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 diff --git a/novelwriter/core/project.py b/novelwriter/core/project.py index badeba21..2a78f9eb 100644 --- a/novelwriter/core/project.py +++ b/novelwriter/core/project.py @@ -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 . """ +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 diff --git a/novelwriter/core/sessions.py b/novelwriter/core/sessions.py new file mode 100644 index 00000000..7fe3fd3a --- /dev/null +++ b/novelwriter/core/sessions.py @@ -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 . +""" +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 diff --git a/novelwriter/core/spellcheck.py b/novelwriter/core/spellcheck.py index 884de15f..b5cac3a2 100644 --- a/novelwriter/core/spellcheck.py +++ b/novelwriter/core/spellcheck.py @@ -1,7 +1,6 @@ """ novelWriter – Spell Check Classes ================================= -Wrapper classes for spell checking tools File History: Created: 2019-06-11 [0.1.5] @@ -22,29 +21,37 @@ General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see . """ +from __future__ import annotations +import json import logging -from collections import namedtuple +from typing import TYPE_CHECKING, Iterator from pathlib import Path from novelwriter.error import logException +from novelwriter.constants import nwFiles + +if TYPE_CHECKING: # pragma: no cover + from novelwriter.core.project import NWProject logger = logging.getLogger(__name__) class NWSpellEnchant: + """Core: Enchant Spell Checking Wrapper - def __init__(self): - - self._theDict = None - self._projDict = set() - self._projectDict = None - self._spellLanguage = None - self._theBroker = None + This is a rapper class for Enchant to keep the API consistent + between spell check tools. + """ + def __init__(self, project: NWProject): + self._project = project + self._dictObj = FakeEnchant() + self._userDict = UserDictionary(project) + self._language = None + self._broker = None logger.debug("Enchant spell checking activated") - return ## @@ -52,43 +59,43 @@ class NWSpellEnchant: ## @property - def spellLanguage(self): - return self._spellLanguage + def spellLanguage(self) -> str | None: + return self._language ## # Setters ## - def setLanguage(self, theLang, projectDict=None): + def setLanguage(self, language: str | None): """Load a dictionary for the language specified in the config. If that fails, we load a mock dictionary so that lookups don't crash. Note that enchant will allow loading an empty string as a tag, but this will fail later on. See issue #1096. """ - self._theBroker = None - self._theDict = None - self._spellLanguage = None + self._dictObj = FakeEnchant() + self._broker = None + self._language = None try: import enchant - if theLang and enchant.dict_exists(theLang): - self._theBroker = enchant.Broker() - self._theDict = self._theBroker.request_dict(theLang) - self._spellLanguage = theLang - logger.debug("Enchant spell checking for language '%s' loaded", theLang) + if language and enchant.dict_exists(language): + self._broker = enchant.Broker() + self._dictObj = self._broker.request_dict(language) + self._language = language + logger.debug("Enchant spell checking for language '%s' loaded", language) else: - logger.warning("Enchant found no dictionary for language '%s'", theLang) + logger.warning("Enchant found no dictionary for language '%s'", language) except Exception: - logger.error("Failed to load enchant spell checking for language '%s'", theLang) + logger.error("Failed to load enchant spell checking for language '%s'", language) - if self._theDict is None: - self._theDict = FakeEnchant() + if self._dictObj is None: + self._dictObj = FakeEnchant() else: - self._readProjectDictionary(projectDict) - for pWord in self._projDict: - self._theDict.add_to_session(pWord) + self._userDict.load() + for pWord in self._userDict: + self._dictObj.add_to_session(pWord) return @@ -96,47 +103,38 @@ class NWSpellEnchant: # Methods ## - def checkWord(self, theWord): - """Wrapper function for pyenchant. - """ + def checkWord(self, word: str) -> bool: + """Wrapper function for pyenchant.""" try: - return self._theDict.check(theWord) + return bool(self._dictObj.check(word)) except Exception: return True - def suggestWords(self, theWord): - """Wrapper function for pyenchant. - """ + def suggestWords(self, word: str) -> list[str]: + """Wrapper function for pyenchant.""" try: - return self._theDict.suggest(theWord) + return self._dictObj.suggest(word) except Exception: return [] - def addWord(self, newWord): - """Add a word to the project dictionary. - """ + def addWord(self, word: str) -> bool: + """Add a word to the project dictionary.""" + word = word.strip() + if not word: + return False try: - self._theDict.add_to_session(newWord) + self._dictObj.add_to_session(word) except Exception: return False - if self._projectDict is not None and newWord not in self._projDict: - newWord = newWord.strip() - try: - with open(self._projectDict, mode="a+", encoding="utf-8") as outFile: - outFile.write("%s\n" % newWord) - self._projDict.add(newWord) - except Exception: - logger.error("Failed to add word to project word list %s", str(self._projectDict)) - logException() - return False - return True + added = self._userDict.add(word) + if added: + self._userDict.save() - return False + return added - def listDictionaries(self): - """Wrapper function for pyenchant. - """ + def listDictionaries(self) -> list[tuple[str, str]]: + """Wrapper function for pyenchant.""" retList = [] try: import enchant @@ -147,73 +145,98 @@ class NWSpellEnchant: return retList - def describeDict(self): + def describeDict(self) -> tuple[str, str]: """Return the tag and provider of the currently loaded dictionary. """ try: - spTag = self._theDict.tag - spName = self._theDict.provider.name + tag = self._dictObj.tag + name = self._dictObj.provider.name # type: ignore except Exception: logger.error("Failed to extract information about the dictionary") logException() - spTag = "" - spName = "" + tag = "" + name = "" - return spTag, spName - - ## - # Internal Functions - ## - - def _readProjectDictionary(self, projectDict): - """Read the content of the project dictionary, and add it to the - lookup lists. - """ - self._projDict = set() - self._projectDict = projectDict - - if not isinstance(projectDict, Path): - return False - - if not projectDict.exists(): - return False - - try: - logger.debug("Loading project word list") - with open(projectDict, mode="r", encoding="utf-8") as wordsFile: - for theLine in wordsFile: - theLine = theLine.strip() - if len(theLine) > 0 and theLine not in self._projDict: - self._projDict.add(theLine) - logger.debug("Project word list contains %d words", len(self._projDict)) - - except Exception: - logger.error("Failed to load project word list") - logException() - return False - - return True + return tag, name # END Class NWSpellEnchant class FakeEnchant: - """Fallback for when Enchant is selected, but not installed. - """ + """Fallback for when Enchant is selected, but not installed.""" def __init__(self): + + class FakeProvider: + name = "" + self.tag = "" - self.provider = namedtuple("provider", "name") - self.provider.name = "" + self.provider = FakeProvider() + return - def check(self, theWord): + def check(self, word: str) -> bool: return True - def suggest(self, theWord): + def suggest(self, word) -> list[str]: return [] - def add_to_session(self, theWord): + def add_to_session(self, word: str): return # END Class FakeEnchant + + +class UserDictionary: + + def __init__(self, project: NWProject): + self._project = project + self._words = set() + self._path = None + return + + def __contains__(self, word: str) -> bool: + return word in self._words + + def __iter__(self) -> Iterator[str]: + return iter(self._words) + + def add(self, word: str) -> bool: + """Add a word to the dictionary, and return True if it was + added, or False if it already existed. + """ + if word in self._words: + return False + self._words.add(word) + return True + + def load(self): + """Load the user's dictionary.""" + self._path = self._project.storage.getMetaFile(nwFiles.DICT_FILE) + if not isinstance(self._path, Path): + return + try: + with open(self._path, mode="r", encoding="utf-8") as fObj: + data = json.load(fObj) + self._words = set(data.get("novelWriter.userDict", [])) + except Exception: + logger.error("Failed to load user dictionary") + logException() + return + + def save(self): + """Save the user's dictionary.""" + if self._path is None: + self._path = self._project.storage.getMetaFile(nwFiles.DICT_FILE) + if not isinstance(self._path, Path): + return + try: + with open(self._path, mode="w", encoding="utf-8") as fObj: + data = {"novelWriter.userDict": list(self._words)} + json.dump(data, fObj, indent=2) + except Exception: + logger.error("Failed to save user dictionary") + logException() + return + +# END Class UserDictionary diff --git a/novelwriter/core/storage.py b/novelwriter/core/storage.py index 5fed4fce..2b96c21b 100644 --- a/novelwriter/core/storage.py +++ b/novelwriter/core/storage.py @@ -23,6 +23,7 @@ along with this program. If not, see . """ from __future__ import annotations +import json import logging from time import time @@ -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 diff --git a/novelwriter/dialogs/about.py b/novelwriter/dialogs/about.py index d2f7d512..36b8bc99 100644 --- a/novelwriter/dialogs/about.py +++ b/novelwriter/dialogs/about.py @@ -114,7 +114,7 @@ class GuiAbout(QDialog): return - def __del__(self): + def __del__(self): # pragma: no cover logger.debug("Delete: GuiAbout") return diff --git a/novelwriter/dialogs/docmerge.py b/novelwriter/dialogs/docmerge.py index 71111f0a..49e3d5f1 100644 --- a/novelwriter/dialogs/docmerge.py +++ b/novelwriter/dialogs/docmerge.py @@ -113,7 +113,7 @@ class GuiDocMerge(QDialog): return - def __del__(self): + def __del__(self): # pragma: no cover logger.debug("Delete: GuiDocMerge") return diff --git a/novelwriter/dialogs/docsplit.py b/novelwriter/dialogs/docsplit.py index c08c017b..58396879 100644 --- a/novelwriter/dialogs/docsplit.py +++ b/novelwriter/dialogs/docsplit.py @@ -142,7 +142,7 @@ class GuiDocSplit(QDialog): return - def __del__(self): + def __del__(self): # pragma: no cover logger.debug("Delete: GuiDocSplit") return diff --git a/novelwriter/dialogs/preferences.py b/novelwriter/dialogs/preferences.py index 5d89cadc..9878ad51 100644 --- a/novelwriter/dialogs/preferences.py +++ b/novelwriter/dialogs/preferences.py @@ -87,7 +87,7 @@ class GuiPreferences(NPagedDialog): return - def __del__(self): + def __del__(self): # pragma: no cover logger.debug("Delete: GuiPreferences") return diff --git a/novelwriter/dialogs/projdetails.py b/novelwriter/dialogs/projdetails.py index d4cff393..e4960b57 100644 --- a/novelwriter/dialogs/projdetails.py +++ b/novelwriter/dialogs/projdetails.py @@ -82,7 +82,7 @@ class GuiProjectDetails(NPagedDialog): return - def __del__(self): + def __del__(self): # pragma: no cover logger.debug("Delete: GuiProjectDetails") return diff --git a/novelwriter/dialogs/projload.py b/novelwriter/dialogs/projload.py index 8fa3ec7a..04cef031 100644 --- a/novelwriter/dialogs/projload.py +++ b/novelwriter/dialogs/projload.py @@ -151,7 +151,7 @@ class GuiProjectLoad(QDialog): return - def __del__(self): + def __del__(self): # pragma: no cover logger.debug("Delete: GuiProjectLoad") return diff --git a/novelwriter/dialogs/projsettings.py b/novelwriter/dialogs/projsettings.py index aac956cc..f496b5e8 100644 --- a/novelwriter/dialogs/projsettings.py +++ b/novelwriter/dialogs/projsettings.py @@ -97,7 +97,7 @@ class GuiProjectSettings(NPagedDialog): return - def __del__(self): + def __del__(self): # pragma: no cover logger.debug("Delete: GuiProjectSettings") return diff --git a/novelwriter/dialogs/updates.py b/novelwriter/dialogs/updates.py index 15e40f8e..4b8afad1 100644 --- a/novelwriter/dialogs/updates.py +++ b/novelwriter/dialogs/updates.py @@ -117,7 +117,7 @@ class GuiUpdates(QDialog): return - def __del__(self): + def __del__(self): # pragma: no cover logger.debug("Delete: GuiUpdates") return diff --git a/novelwriter/dialogs/wordlist.py b/novelwriter/dialogs/wordlist.py index f2e7ddfe..55503376 100644 --- a/novelwriter/dialogs/wordlist.py +++ b/novelwriter/dialogs/wordlist.py @@ -1,7 +1,6 @@ """ novelWriter – GUI User Wordlist =============================== -Class holding the user's wordlist dialog File History: Created: 2021-02-12 [1.2rc1] @@ -22,28 +21,31 @@ General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see . """ +from __future__ import annotations import logging -from pathlib import Path +from typing import TYPE_CHECKING from PyQt5.QtCore import Qt from PyQt5.QtWidgets import ( - QDialog, QDialogButtonBox, QVBoxLayout, QHBoxLayout, QListWidget, - QAbstractItemView, QPushButton, QLineEdit, QLabel + QAbstractItemView, QDialog, QDialogButtonBox, QHBoxLayout, QLabel, + QLineEdit, QListWidget, QListWidgetItem, QPushButton, QVBoxLayout ) from novelwriter import CONFIG from novelwriter.enum import nwAlert -from novelwriter.error import logException -from novelwriter.constants import nwFiles +from novelwriter.core.spellcheck import UserDictionary + +if TYPE_CHECKING: # pragma: no cover + from novelwriter.guimain import GuiMain logger = logging.getLogger(__name__) class GuiWordList(QDialog): - def __init__(self, mainGui): + def __init__(self, mainGui: GuiMain): super().__init__(parent=mainGui) logger.debug("Create: GuiWordList") @@ -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()) diff --git a/novelwriter/error.py b/novelwriter/error.py index 441c138f..73c08290 100644 --- a/novelwriter/error.py +++ b/novelwriter/error.py @@ -27,6 +27,7 @@ import sys import random import logging +from PyQt5.QtGui import QFont, QFontDatabase from PyQt5.QtCore import Qt from PyQt5.QtWidgets import ( qApp, QDialog, QGridLayout, QStyle, QPlainTextEdit, QLabel, @@ -74,7 +75,12 @@ class NWErrorMessage(QDialog): self.msgHead.setOpenExternalLinks(True) self.msgHead.setWordWrap(True) + font = QFont() + font.setPointSize(round(0.9*self.font().pointSize())) + font.setFamily(QFontDatabase.systemFont(QFontDatabase.FixedFont).family()) + self.msgBody = QPlainTextEdit() + self.msgBody.setFont(font) self.msgBody.setReadOnly(True) self.btnBox = QDialogButtonBox(QDialogButtonBox.Close) @@ -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( "

An unhandled error has been encountered.

" "

Please report this error by submitting an issue report on " - "GitHub, providing a description and including the error " + "GitHub, providing a description, and including the error " "message and traceback shown below.

" f"

URL: {nwConst.URL_REPORT}

" ) diff --git a/novelwriter/gui/doceditor.py b/novelwriter/gui/doceditor.py index 499ddf2a..37ef9fe7 100644 --- a/novelwriter/gui/doceditor.py +++ b/novelwriter/gui/doceditor.py @@ -52,7 +52,7 @@ from PyQt5.QtWidgets import ( from novelwriter import CONFIG from novelwriter.enum import nwAlert, nwDocAction, nwDocInsert, nwDocMode, nwItemClass from novelwriter.common import minmax, transferCase -from novelwriter.constants import nwConst, nwFiles, nwKeyWords, nwUnicode +from novelwriter.constants import nwConst, nwKeyWords, nwUnicode from novelwriter.core.index import countWords from novelwriter.core.spellcheck import NWSpellEnchant from novelwriter.gui.dochighlight import GuiDocHighlighter @@ -131,7 +131,7 @@ class GuiDocEditor(QTextEdit): self.docSearch = GuiDocEditSearch(self) # Syntax - self.spEnchant = NWSpellEnchant() + self.spEnchant = NWSpellEnchant(self.theProject) self.highLight = GuiDocHighlighter(qDoc, self.mainGui, self.spEnchant) # Context Menu @@ -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)) diff --git a/novelwriter/gui/outline.py b/novelwriter/gui/outline.py index 03c6c5bf..806f4d6b 100644 --- a/novelwriter/gui/outline.py +++ b/novelwriter/gui/outline.py @@ -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) diff --git a/novelwriter/tools/lipsum.py b/novelwriter/tools/lipsum.py index af152397..5c6d41ca 100644 --- a/novelwriter/tools/lipsum.py +++ b/novelwriter/tools/lipsum.py @@ -111,7 +111,7 @@ class GuiLipsum(QDialog): return - def __del__(self): + def __del__(self): # pragma: no cover logger.debug("Delete: GuiLipsum") return diff --git a/novelwriter/tools/manusbuild.py b/novelwriter/tools/manusbuild.py index 4f24ec1b..8ba93af7 100644 --- a/novelwriter/tools/manusbuild.py +++ b/novelwriter/tools/manusbuild.py @@ -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 diff --git a/novelwriter/tools/manuscript.py b/novelwriter/tools/manuscript.py index 5308dc63..b2344aac 100644 --- a/novelwriter/tools/manuscript.py +++ b/novelwriter/tools/manuscript.py @@ -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 diff --git a/novelwriter/tools/manussettings.py b/novelwriter/tools/manussettings.py index 53da155d..903284a0 100644 --- a/novelwriter/tools/manussettings.py +++ b/novelwriter/tools/manussettings.py @@ -167,8 +167,7 @@ class GuiBuildSettings(QDialog): return - def __del__(self): - """For debug use only.""" + def __del__(self): # pragma: no cover logger.debug("Delete: GuiBuildSettings") def loadContent(self): @@ -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) diff --git a/novelwriter/tools/projwizard.py b/novelwriter/tools/projwizard.py index 1fe6cdc2..f147dd0f 100644 --- a/novelwriter/tools/projwizard.py +++ b/novelwriter/tools/projwizard.py @@ -80,7 +80,7 @@ class GuiProjectWizard(QWizard): return - def __del__(self): + def __del__(self): # pragma: no cover logger.debug("Delete: GuiProjectWizard") return diff --git a/novelwriter/tools/writingstats.py b/novelwriter/tools/writingstats.py index ce7fc184..31f91ba7 100644 --- a/novelwriter/tools/writingstats.py +++ b/novelwriter/tools/writingstats.py @@ -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 . """ +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 diff --git a/tests/conftest.py b/tests/conftest.py index a12bdecc..acb1ae19 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -61,8 +61,7 @@ def resetConfigVars(): @pytest.fixture(scope="session", autouse=True) def sessionFixture(): - """A session wide fixture to set up the test environment. - """ + """A session wide fixture to set up the test environment.""" if _TMP_ROOT.exists(): shutil.rmtree(_TMP_ROOT) _TMP_ROOT.mkdir() @@ -111,8 +110,7 @@ def tstPaths(): @pytest.fixture(scope="function") def fncPath(): - """A temporary folder for a single test function. - """ + """A temporary folder for a single test function.""" fncPath = _TMP_ROOT / "function" if fncPath.is_dir(): shutil.rmtree(fncPath) @@ -139,16 +137,14 @@ def projPath(fncPath): @pytest.fixture(scope="function") def mockGUI(): - """Create a mock instance of novelWriter's main GUI class. - """ + """Create a mock instance of novelWriter's main GUI class.""" theGui = MockGuiMain() return theGui @pytest.fixture(scope="function") def nwGUI(qtbot, monkeypatch, functionFixture): - """Create an instance of the novelWriter GUI. - """ + """Create an instance of the novelWriter GUI.""" monkeypatch.setattr(QMessageBox, "warning", lambda *a: QMessageBox.Ok) monkeypatch.setattr(QMessageBox, "critical", lambda *a: QMessageBox.Ok) monkeypatch.setattr(QMessageBox, "information", lambda *a: QMessageBox.Ok) diff --git a/tests/reference/coreIndex_LoadSave_tagsIndex.json b/tests/reference/coreIndex_LoadSave_tagsIndex.json index 738e7916..63be8658 100644 --- a/tests/reference/coreIndex_LoadSave_tagsIndex.json +++ b/tests/reference/coreIndex_LoadSave_tagsIndex.json @@ -1,10 +1,10 @@ { - "tagsIndex": { + "novelWriter.tagsIndex": { "Bod": {"handle": "4c4f28287af27", "heading": "T0001", "class": "CHARACTER"}, "Main": {"handle": "2426c6f0ca922", "heading": "T0001", "class": "PLOT"}, "Europe": {"handle": "04468803b92e1", "heading": "T0001", "class": "WORLD"} }, - "itemIndex": { + "novelWriter.itemIndex": { "7a992350f3eb6": { "headings": { "T0001": {"level": "H1", "title": "Lorem Ipsum", "line": 1, "tag": "", "cCount": 230, "wCount": 40, "pCount": 3, "synopsis": ""} diff --git a/tests/test_core/test_core_index.py b/tests/test_core/test_core_index.py index 5fada997..317e176b 100644 --- a/tests/test_core/test_core_index.py +++ b/tests/test_core/test_core_index.py @@ -125,7 +125,7 @@ def testCoreIndex_LoadSave(monkeypatch, prjLipsum, mockGUI, tstPaths): assert theIndex.indexBroken is True # Write an index file that passes loading, but is still empty - writeFile(projFile, '{"tagsIndex": {}, "itemIndex": {}}') + writeFile(projFile, '{"novelWriter.tagsIndex": {}, "novelWriter.itemIndex": {}}') assert theIndex.loadIndex() is True assert theIndex.indexBroken is False @@ -1071,13 +1071,13 @@ def testCoreIndex_ItemIndex(mockGUI, fncPath, mockRnd): assert nStruct[3][0] == uHandle # Novel structure with root handle set - nStruct = list(itemIndex.iterNovelStructure(rootHandle=C.hNovelRoot)) + nStruct = list(itemIndex.iterNovelStructure(rHandle=C.hNovelRoot)) assert len(nStruct) == 3 assert nStruct[0][0] == nHandle assert nStruct[1][0] == cHandle assert nStruct[2][0] == sHandle - nStruct = list(itemIndex.iterNovelStructure(rootHandle=mHandle)) + nStruct = list(itemIndex.iterNovelStructure(rHandle=mHandle)) assert len(nStruct) == 1 assert nStruct[0][0] == uHandle diff --git a/tests/test_core/test_core_options.py b/tests/test_core/test_core_options.py index c241504e..dba4bdea 100644 --- a/tests/test_core/test_core_options.py +++ b/tests/test_core/test_core_options.py @@ -42,15 +42,17 @@ def testCoreOptions_LoadSave(monkeypatch, mockGUI, fncPath): # Write a test file optFile = metaDir / nwFiles.OPTS_FILE optFile.write_text(json.dumps({ - "GuiProjectSettings": { - "winWidth": 570, - "winHeight": 375, - "replaceColW": 130, - "statusColW": 130, - "importColW": 130 - }, - "MockGroup": { - "mockItem": None, + "novelWriter.guiOptions": { + "GuiProjectSettings": { + "winWidth": 570, + "winHeight": 375, + "replaceColW": 130, + "statusColW": 130, + "importColW": 130 + }, + "MockGroup": { + "mockItem": None, + }, }, }), encoding="utf-8") @@ -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, diff --git a/tests/test_core/test_core_project.py b/tests/test_core/test_core_project.py index 05e4e6cc..2355cd4f 100644 --- a/tests/test_core/test_core_project.py +++ b/tests/test_core/test_core_project.py @@ -21,9 +21,7 @@ along with this program. If not, see . import pytest -from time import time from shutil import copyfile -from pathlib import Path from zipfile import ZipFile from mocked import causeOSError @@ -31,8 +29,6 @@ from tools import C, cmpFiles, writeFile, buildTestProject, XML_IGNORE from novelwriter import CONFIG from novelwriter.enum import nwItemClass, nwItemType, nwItemLayout -from novelwriter.common import formatTimeStamp -from novelwriter.constants import nwFiles from novelwriter.core.tree import NWTree from novelwriter.core.index import NWIndex from novelwriter.core.project import NWProject @@ -468,8 +464,7 @@ def testCoreProject_StatusImport(mockGUI, fncPath, mockRnd): @pytest.mark.core def testCoreProject_Methods(monkeypatch, mockGUI, fncPath, mockRnd): - """Test other project class methods and functions. - """ + """Test other project class methods and functions.""" theProject = NWProject(mockGUI) buildTestProject(theProject, fncPath) @@ -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 diff --git a/tests/test_core/test_core_sessions.py b/tests/test_core/test_core_sessions.py new file mode 100644 index 00000000..d7ee58f1 --- /dev/null +++ b/tests/test_core/test_core_sessions.py @@ -0,0 +1,114 @@ +""" +novelWriter – NWSessionLog Class Tester +======================================= + +This file is a part of novelWriter +Copyright 2018–2023, Veronica Berglyd Olsen + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, but +WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +""" + +import pytest + +from time import sleep +from pathlib import Path + +from tools import buildTestProject +from mocked import causeOSError + +from novelwriter.constants import nwFiles +from novelwriter.core.project import NWProject +from novelwriter.core.sessions import NWSessionLog + + +@pytest.mark.core +def testCoreSessions_Main(monkeypatch, mockGUI, fncPath): + """Test log file handling of the NWSessionLog class.""" + project = NWProject(mockGUI) + buildTestProject(project, fncPath) + + logFile = project.storage.getMetaFile(nwFiles.SESS_FILE) + assert isinstance(logFile, Path) + + # Set some moch word counts + project.data.setInitCounts(50, 60) + project.data.setCurrCounts(160, 150) + + # The project init should already have created the session + sessLog = project.session + assert isinstance(sessLog, NWSessionLog) + assert sessLog.start > 0.0 + + # Starting the session again should reset the timer + currTime = sessLog.start + sleep(0.015) # Make sure we don't hit clock resolution issues on Windows + sessLog.startSession() + assert sessLog.start > currTime + + # There should not be a logfile + assert not logFile.exists() + assert len(list(sessLog.iterRecords())) == 0 + + # Create the initial and first records + assert sessLog.appendSession(0.8) is True + assert logFile.exists() # Logfile now exists + records = list(sessLog.iterRecords()) + assert len(records) == 2 + assert records[0]["type"] == "initial" + assert records[0]["offset"] == 110 # Sum of initial word counts + assert records[1]["type"] == "record" + assert records[1]["novel"] == 160 + assert records[1]["notes"] == 150 + assert records[1]["idle"] == 1 # Should be rounded to full seconds + + # Adding another record without changing word count should do nothing + project.data.setInitCounts(160, 150) + project.data.setCurrCounts(160, 150) + assert sessLog.appendSession(1.6) is False + assert len(list(sessLog.iterRecords())) == 2 + + # But adding when count has changed should + project.data.setInitCounts(160, 150) + project.data.setCurrCounts(270, 240) + sessLog._start -= 350.0 # Backdate the session start to allow logging + assert sessLog.appendSession(1.6) is True + records = list(sessLog.iterRecords()) + assert len(records) == 3 + assert records[2]["novel"] == 270 + assert records[2]["notes"] == 240 + assert records[2]["idle"] == 2 # Should be rounded to full seconds + + # Make file path unresolvable, and try appending another record + with monkeypatch.context() as mp: + mp.setattr("novelwriter.core.storage.NWStorage.getMetaFile", lambda *a: None) + assert sessLog.appendSession(1.6) is False + assert len(list(sessLog.iterRecords())) == 3 + + # Make the file open fail, and check that it's handled + with monkeypatch.context() as mp: + mp.setattr("builtins.open", causeOSError) + assert sessLog.appendSession(1.6) is False + assert len(list(sessLog.iterRecords())) == 3 + + # Make the file load fail, and check that it's handled + with monkeypatch.context() as mp: + mp.setattr("builtins.open", causeOSError) + assert len(list(sessLog.iterRecords())) == 0 + + # Make file path unresolvable + with monkeypatch.context() as mp: + mp.setattr("novelwriter.core.storage.NWStorage.getMetaFile", lambda *a: None) + assert len(list(sessLog.iterRecords())) == 0 + +# END Test testCoreSessions_Main diff --git a/tests/test_core/test_core_spellcheck.py b/tests/test_core/test_core_spellcheck.py index 1782adb4..9f2e59de 100644 --- a/tests/test_core/test_core_spellcheck.py +++ b/tests/test_core/test_core_spellcheck.py @@ -21,34 +21,119 @@ along with this program. If not, see . import sys import pytest +import enchant +from pathlib import Path + +from tools import buildTestProject from mocked import causeOSError -from tools import readFile, writeFile -from novelwriter.core.spellcheck import FakeEnchant, NWSpellEnchant +from novelwriter.constants import nwFiles +from novelwriter.core.project import NWProject +from novelwriter.core.spellcheck import FakeEnchant, NWSpellEnchant, UserDictionary @pytest.mark.core -def testCoreSpell_FakeEnchant(monkeypatch): - """Test the FakeEnchant spell checker fallback. - """ +def testCoreSpell_UserDictionary(monkeypatch, mockGUI, fncPath): + """Test the UserDictionary class.""" + project = NWProject(mockGUI) + buildTestProject(project, fncPath) + + # Check that there is no file before we start + dictFile = project.storage.getMetaFile(nwFiles.DICT_FILE) + assert isinstance(dictFile, Path) + assert not dictFile.exists() + + # Add a couple of words + userDict = UserDictionary(project) + assert userDict.add("foo") is True + assert userDict.add("bar") is True + assert userDict.add("bar") is False # No duplicates + + # Check that we have them + assert "foo" in userDict + assert "bar" in userDict + + # Check the iterator + assert sorted(userDict) == ["bar", "foo"] + + # Save the file, but fail + assert userDict._path is None + with monkeypatch.context() as mp: + mp.setattr("builtins.open", causeOSError) + userDict.save() + + # There should be no file, but the file path should now be cached + assert userDict._path == dictFile + assert not dictFile.exists() + + # Break the path check + userDict._path = None + with monkeypatch.context() as mp: + mp.setattr("novelwriter.core.storage.NWStorage.getMetaFile", lambda *a: None) + userDict.save() + + # There should still be no file + assert not dictFile.exists() + + # Save proper + userDict.save() + assert dictFile.exists() + + # Clear the dictionary + userDict._words = set() + assert sorted(userDict) == [] + + # Load the file, but fail + userDict._path = None + with monkeypatch.context() as mp: + mp.setattr("builtins.open", causeOSError) + userDict.load() + + # Path is now set, but no words + assert userDict._path == dictFile + assert sorted(userDict) == [] + + # Break the path check + userDict._path = None + with monkeypatch.context() as mp: + mp.setattr("novelwriter.core.storage.NWStorage.getMetaFile", lambda *a: None) + userDict.load() + + # Path is now None, and no words + assert userDict._path is None + assert sorted(userDict) == [] + + # Load the words again, properly + userDict.load() + assert sorted(userDict) == ["bar", "foo"] + +# END Test testCoreSpell_UserDictionary + + +@pytest.mark.core +def testCoreSpell_FakeEnchant(monkeypatch, mockGUI, fncPath): + """Test the FakeEnchant spell checker fallback.""" + project = NWProject(mockGUI) + buildTestProject(project, fncPath) + # Make package import fail with monkeypatch.context() as mp: mp.setitem(sys.modules, "enchant", None) - spChk = NWSpellEnchant() - spChk.setLanguage("en_US", "") - 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 diff --git a/tests/test_core/test_core_storage.py b/tests/test_core/test_core_storage.py index c0410964..50db40c8 100644 --- a/tests/test_core/test_core_storage.py +++ b/tests/test_core/test_core_storage.py @@ -19,22 +19,24 @@ You should have received a copy of the GNU General Public License along with this program. If not, see . """ -from zipfile import ZipFile +import json import pytest +from pathlib import Path +from zipfile import ZipFile + from tools import C, buildTestProject, writeFile from mocked import causeOSError from novelwriter import CONFIG from novelwriter.constants import nwFiles from novelwriter.core.project import NWProject -from novelwriter.core.storage import NWStorage +from novelwriter.core.storage import NWStorage, _LegacyStorage from novelwriter.core.projectxml import ProjectXMLReader, ProjectXMLWriter class MockProject: """Test class for projects.""" - pass @@ -112,7 +114,7 @@ def testCoreStorage_LockFile(monkeypatch, fncPath): """Test the project lock file.""" monkeypatch.setattr("novelwriter.core.storage.time", lambda: 1000.0) - storage = NWStorage(MockProject()) + storage = NWStorage(MockProject()) # type: ignore assert storage.isOpen() is False # Project not open, so cannot read/write lock file @@ -169,10 +171,46 @@ def testCoreStorage_LockFile(monkeypatch, fncPath): # END Test testCoreStorage_LockFile +@pytest.mark.core +def testCoreStorage_ZipIt(monkeypatch, mockGUI, fncPath, tstPaths, mockRnd): + """Test making a zip archive of a project.""" + zipFile = tstPaths.tmpDir / "project.zip" + + theProject = NWProject(mockGUI) + storage = theProject.storage + assert storage.zipIt(zipFile) is False + + # Make a project + mockRnd.reset() + buildTestProject(theProject, fncPath) + + # Fail to create archive + with monkeypatch.context() as mp: + mp.setattr("novelwriter.core.storage.ZipFile.write", causeOSError) + assert storage.zipIt(zipFile) is False + + # Create archive + assert storage.zipIt(zipFile) is True + + # Check content + with ZipFile(zipFile, mode="r") as archive: + names = archive.namelist() + assert nwFiles.PROJ_FILE in names + assert f"meta/{nwFiles.OPTS_FILE}" in names + assert f"meta/{nwFiles.INDEX_FILE}" in names + assert f"content/{C.hTitlePage}.nwd" in names + assert f"content/{C.hChapterDoc}.nwd" in names + assert f"content/{C.hSceneDoc}.nwd" in names + + theProject.closeProject() + +# END Test testCoreStorage_ZipIt + + @pytest.mark.core def testCoreStorage_PrepareStorage(monkeypatch, fncPath): """Test the project path preparation functions.""" - storage = NWStorage(MockProject()) + storage = NWStorage(MockProject()) # type: ignore assert storage.isOpen() is False # No path set @@ -208,9 +246,18 @@ def testCoreStorage_PrepareStorage(monkeypatch, fncPath): storage._runtimePath = fncPath assert storage._prepareStorage(checkLegacy=False, newProject=True) is False - # Legacy Data Folder - # ================== +# END Test testCoreStorage_PrepareStorage + + +@pytest.mark.core +def testCoreStorage_LegacyDataFolder(monkeypatch, fncPath): + """Test project file format 1.0 folder structure conversion.""" + project = MockProject() + storage = NWStorage(project) # type: ignore + assert storage.isOpen() is False storage._runtimePath = fncPath + assert storage._prepareStorage() is True + legacy = _LegacyStorage(project) # type: ignore data = [] files = [] @@ -235,7 +282,7 @@ def testCoreStorage_PrepareStorage(monkeypatch, fncPath): # Process folders for i in range(9): - storage._legacyDataFolder(fncPath, data[i]) + legacy.legacyDataFolder(fncPath, data[i]) # Files form 0 to 8 should now be in content for c in "012345678": @@ -250,14 +297,14 @@ def testCoreStorage_PrepareStorage(monkeypatch, fncPath): assert data[8].exists() # So does folder X, which is invalid - storage._legacyDataFolder(fncPath, data[16]) + legacy.legacyDataFolder(fncPath, data[16]) assert data[16].exists() # Fail cleanup of folder 9 with monkeypatch.context() as mp: mp.setattr("pathlib.Path.rename", causeOSError) mp.setattr("pathlib.Path.unlink", causeOSError) - storage._legacyDataFolder(fncPath, data[9]) + legacy.legacyDataFolder(fncPath, data[9]) assert data[9].exists() assert not (fncPath / "content" / "9000000000009.nwd").exists() @@ -266,10 +313,24 @@ def testCoreStorage_PrepareStorage(monkeypatch, fncPath): for c in "0123456789abcdef": assert (fncPath / "content" / f"{c}00000000000{c}.nwd").exists() - # Deprecated Files - # ================ +# END Test testCoreStorage_LegacyDataFolder + + +@pytest.mark.core +def testCoreStorage_DeprecatedFiles(monkeypatch, fncPath): + """Test cleanup of deprecated files.""" + project = MockProject() + storage = NWStorage(project) # type: ignore + assert storage.isOpen() is False + storage._runtimePath = fncPath + assert storage._prepareStorage() is True + legacy = _LegacyStorage(project) # type: ignore + + # Files/Folders to be Deleted or Renamed + # ====================================== remove = [ + fncPath / "meta" / "tagsIndex.json", fncPath / "meta" / "mainOptions.json", fncPath / "meta" / "exportOptions.json", fncPath / "meta" / "outlineOptions.json", @@ -286,48 +347,130 @@ def testCoreStorage_PrepareStorage(monkeypatch, fncPath): with monkeypatch.context() as mp: mp.setattr("pathlib.Path.unlink", causeOSError) - storage._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 diff --git a/tests/test_dialogs/test_dlg_wordlist.py b/tests/test_dialogs/test_dlg_wordlist.py index 8781a82d..8d698851 100644 --- a/tests/test_dialogs/test_dlg_wordlist.py +++ b/tests/test_dialogs/test_dlg_wordlist.py @@ -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() diff --git a/tests/test_gui/test_gui_outline.py b/tests/test_gui/test_gui_outline.py index f208f246..5aaf3117 100644 --- a/tests/test_gui/test_gui_outline.py +++ b/tests/test_gui/test_gui_outline.py @@ -33,8 +33,7 @@ from novelwriter.enum import nwItemClass, nwOutline, nwView @pytest.mark.gui def testGuiOutline_Main(qtbot, monkeypatch, nwGUI, projPath): - """Test the outline view. - """ + """Test the outline view.""" # Create a project buildTestProject(nwGUI, projPath) @@ -83,52 +82,61 @@ def testGuiOutline_Main(qtbot, monkeypatch, nwGUI, projPath): # Save header state not allowed outlineTree._lastBuild = 0 outlineTree._saveHeaderState() - assert pOptions.getValue("GuiOutline", "headerOrder", []) == [] + assert pOptions.getValue("GuiOutline", "columnState", {}) == {} # Allow saving header state outlineTree._lastBuild = time.time() outlineTree._saveHeaderState() - assert pOptions.getValue("GuiOutline", "headerOrder", []) == colNames + assert list(pOptions.getValue("GuiOutline", "columnState", {}).keys()) == colNames assert outlineTree._treeOrder == colItems assert outlineTree._colWidth == colWidth assert outlineTree._colHidden == colHidden # Get default values - optItems = pOptions.getValue("GuiOutline", "headerOrder", []) - optWidth = pOptions.getValue("GuiOutline", "columnWidth", {}) - optHidden = pOptions.getValue("GuiOutline", "columnHidden", {}) + columnState = pOptions.getValue("GuiOutline", "columnState", {}) # Add invalid column name - pOptions.setValue("GuiOutline", "headerOrder", optItems + ["blabla"]) - outlineTree._loadHeaderState() - assert outlineTree._treeOrder == colItems - assert outlineTree._colHidden == colHidden - - # Add duplicate column name - pOptions.setValue("GuiOutline", "headerOrder", optItems + [optItems[-1]]) + newState = columnState.copy() + newState.update({"blabla": (False, 42)}) + pOptions.setValue("GuiOutline", "columnState", newState) outlineTree._loadHeaderState() assert outlineTree._treeOrder == colItems assert outlineTree._colHidden == colHidden # Invalid column width data - pOptions.setValue("GuiOutline", "headerOrder", optItems) - pOptions.setValue("GuiOutline", "columnWidth", {"blabla": None}) + newState = columnState.copy() + newState.update({"TITLE": (False, None)}) + pOptions.setValue("GuiOutline", "columnState", newState) outlineTree._loadHeaderState() assert outlineTree._treeOrder == colItems assert outlineTree._colHidden == colHidden - # Invalid column width data - pOptions.setValue("GuiOutline", "headerOrder", optItems) - pOptions.setValue("GuiOutline", "columnWidth", optWidth) - pOptions.setValue("GuiOutline", "columnHidden", {"bloabla": None}) + # Invalid column state data + newState = columnState.copy() + newState.update({"TITLE": None}) + pOptions.setValue("GuiOutline", "columnState", newState) outlineTree._loadHeaderState() assert outlineTree._treeOrder == colItems assert outlineTree._colHidden == colHidden + # Drop a few columns + newState = columnState.copy() + del newState[nwOutline.CHAR.name] + del newState[nwOutline.WORLD.name] + del newState[nwOutline.LINE.name] + pOptions.setValue("GuiOutline", "columnState", newState) + outlineTree._loadHeaderState() + assert len(outlineTree._treeOrder) == len(colItems) + assert len(outlineTree._colHidden) == len(colHidden) + assert nwOutline.CHAR in outlineTree._treeOrder + assert nwOutline.CHAR in outlineTree._colHidden + assert nwOutline.WORLD in outlineTree._treeOrder + assert nwOutline.WORLD in outlineTree._colHidden + assert nwOutline.LINE in outlineTree._treeOrder + assert nwOutline.LINE in outlineTree._colHidden + # Valid settings - pOptions.setValue("GuiOutline", "headerOrder", optItems) - pOptions.setValue("GuiOutline", "columnWidth", optWidth) - pOptions.setValue("GuiOutline", "columnHidden", optHidden) + pOptions.setValue("GuiOutline", "columnState", columnState) outlineTree._loadHeaderState() assert outlineTree._treeOrder == colItems assert outlineTree._colHidden == colHidden @@ -143,7 +151,9 @@ def testGuiOutline_Main(qtbot, monkeypatch, nwGUI, projPath): # Now no columns should be hidden outlineTree._saveHeaderState() - assert not any(pOptions.getValue("GuiOutline", "columnHidden", None).values()) + hiddenStates = [v[0] for v in pOptions.getValue("GuiOutline", "columnState", {}).values()] + assert len(hiddenStates) == len(columnState) + assert not any(hiddenStates) # qtbot.stop() @@ -152,8 +162,7 @@ def testGuiOutline_Main(qtbot, monkeypatch, nwGUI, projPath): @pytest.mark.gui def testGuiOutline_Content(qtbot, nwGUI, prjLipsum): - """Test the outline view. - """ + """Test the outline view.""" assert nwGUI.openProject(prjLipsum) nwGUI.rebuildIndex() diff --git a/tests/test_tools/test_tools_writingstats.py b/tests/test_tools/test_tools_writingstats.py index af2e4f0c..50ab2e1d 100644 --- a/tests/test_tools/test_tools_writingstats.py +++ b/tests/test_tools/test_tools_writingstats.py @@ -20,10 +20,11 @@ along with this program. If not, see . """ import json +from pathlib import Path import pytest +from tools import getGuiItem, buildTestProject from mocked import causeOSError -from tools import getGuiItem, writeFile, buildTestProject from PyQt5.QtCore import Qt from PyQt5.QtWidgets import QAction, QFileDialog @@ -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 diff --git a/tests/tools.py b/tests/tools.py index 7463e3a1..9951ebc6 100644 --- a/tests/tools.py +++ b/tests/tools.py @@ -19,7 +19,6 @@ You should have received a copy of the GNU General Public License along with this program. If not, see . """ -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)