From dff2d5996b4ddc532107cc1d5ef5b7f7936e73f8 Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Sun, 7 Apr 2024 23:56:40 +0200 Subject: [PATCH] Add new index classes to store special text comments --- novelwriter/core/index.py | 171 +++++++++++++++++++++++++++-- novelwriter/enum.py | 6 +- tests/test_core/test_core_index.py | 6 +- 3 files changed, 169 insertions(+), 14 deletions(-) diff --git a/novelwriter/core/index.py b/novelwriter/core/index.py index 89938e69..aa01b327 100644 --- a/novelwriter/core/index.py +++ b/novelwriter/core/index.py @@ -85,6 +85,7 @@ class NWIndex: # Storage and State self._tagsIndex = TagsIndex() self._itemIndex = ItemIndex(project) + self._textIndex = TextIndex() self._indexBroken = False # TimeStamps @@ -112,6 +113,7 @@ class NWIndex: """Clear the index dictionaries and time stamps.""" self._tagsIndex.clear() self._itemIndex.clear() + self._textIndex.clear() self._indexChange = 0.0 self._rootChange = {} SHARED.indexSignalProxy({"event": "clearIndex"}) @@ -135,6 +137,7 @@ class NWIndex: for tTag in delTags: del self._tagsIndex[tTag] del self._itemIndex[tHandle] + self._textIndex.removeHandle(tHandle) SHARED.indexSignalProxy({ "event": "updateTags", "deleted": delTags, @@ -189,6 +192,7 @@ class NWIndex: try: self._tagsIndex.unpackData(data["novelWriter.tagsIndex"]) self._itemIndex.unpackData(data["novelWriter.itemIndex"]) + self._textIndex.unpackData(data["novelWriter.textIndex"]) except Exception: logger.error("The index content is invalid") logException() @@ -224,10 +228,12 @@ class NWIndex: try: tagsIndex = jsonEncode(self._tagsIndex.packData(), n=1, nmax=2) itemIndex = jsonEncode(self._itemIndex.packData(), n=1, nmax=4) + textIndex = jsonEncode(self._textIndex.packData(), n=1, nmax=3) with open(indexFile, mode="w+", encoding="utf-8") as outFile: outFile.write("{\n") outFile.write(f' "novelWriter.tagsIndex": {tagsIndex},\n') - outFile.write(f' "novelWriter.itemIndex": {itemIndex}\n') + outFile.write(f' "novelWriter.itemIndex": {itemIndex},\n') + outFile.write(f' "novelWriter.textIndex": {textIndex}\n') outFile.write("}\n") except Exception: @@ -301,9 +307,9 @@ class NWIndex: def _scanActive(self, tHandle: str, nwItem: NWItem, text: str, tags: dict[str, bool]) -> None: """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 + nTitle = 0 # Line Number of the previous title + cTitle = TT_NONE # Tag of the current title + pTitle = TT_NONE # Tag of the previous title canSetHead = True # First heading has not yet been set lines = text.splitlines() @@ -335,10 +341,14 @@ class NWIndex: self._indexKeyword(tHandle, line, cTitle, nwItem.itemClass, tags) elif line.startswith("%"): + cStyle, cKey, cText, _, _ = processComment(line) if cTitle != TT_NONE: - cStyle, cMod, cText, _, _ = processComment(line) if cStyle in (nwComment.SYNOPSIS, nwComment.SHORT): self._itemIndex.setHeadingSynopsis(tHandle, cTitle, cText) + if cStyle in (nwComment.SYNOPSIS, nwComment.SHORT): + self._textIndex.summary.add(f"{tHandle}:{cTitle}", tHandle, cText) + elif cStyle == nwComment.FOOTNOTE: + self._textIndex.footnotes.add(cKey, tHandle, cText) # Count words for remaining text after last heading if pTitle != TT_NONE: @@ -790,7 +800,7 @@ class TagsIndex: for key, entry in data.items(): if not isinstance(key, str): - raise ValueError("tagsIndex keys must be a string") + raise ValueError("tagsIndex key must be a string") if not isinstance(entry, dict): raise ValueError("tagsIndex entry is not a dict") @@ -1298,18 +1308,163 @@ class IndexHeading: # END Class IndexHeading +# =============================================================================================== # +# The Text Index Object +# =============================================================================================== # + +class TextIndex: + """Core: Text Index Wrapper Class + + A wrapper class that holds various global text entries. + """ + + __slots__ = ("_summary", "_footnotes") + + def __init__(self) -> None: + self._summary = TextRegistry() + self._footnotes = TextRegistry() + return + + @property + def summary(self) -> TextRegistry: + """Return the summary text registry.""" + return self._summary + + @property + def footnotes(self) -> TextRegistry: + """Return the footnotes text registry.""" + return self._footnotes + + ## + # Methods + ## + + def clear(self) -> None: + """Clear the index.""" + self._summary.clear() + self._footnotes.clear() + return + + def removeHandle(self, handle: str) -> None: + """Remove all entries for a given handle.""" + self._summary.removeHandle(handle) + self._footnotes.removeHandle(handle) + return + + ## + # Pack/Unpack + ## + + def packData(self) -> dict[str, dict]: + """Pack all the text comments into a single dictionary.""" + return { + "summaries": self._summary.packData(), + "footnotes": self._footnotes.packData(), + } + + def unpackData(self, data: dict) -> None: + """Unpack the text comments index.""" + self._summary.unpackData(data.get("summaries", {})) + self._footnotes.unpackData(data.get("footnotes", {})) + return + +# END Class TextIndex + + +class TextRegistry: + """Core: Text Registry Index Wrapper Class + + A wrapper class that holds a category of text entries. + """ + + __slots__ = ("_map", "_text") + + def __init__(self) -> None: + self._map: dict[str, str] = {} + self._text: dict[str, str] = {} + return + + def __len__(self) -> int: + return len(self._text) + + def __getitem__(self, key: str) -> str | None: + return self._text.get(key, (0, None))[1] + + def __contains__(self, key: str) -> bool: + return key in self._text + + ## + # Methods + ## + + def clear(self) -> None: + """Clear the index.""" + self._map.clear() + self._text.clear() + return + + def add(self, key: str, handle: str, text: str) -> None: + """Add a new text entry.""" + self._map[key] = handle + self._text[key] = text + return + + def keysForHandle(self, handle: str) -> list[str]: + """Return all keys for a given handle.""" + return [k for k, v in self._map.items() if v == handle] + + def removeHandle(self, handle: str) -> None: + """Iterate through the data and remove entries for a handle.""" + for key in [k for k, v in self._map.items() if v == handle]: + del self._text[key] + return + + ## + # Pack/Unpack + ## + + def packData(self) -> dict[str, dict[str, str]]: + """Pack all the text entries into a dictionary.""" + return {k: {"handle": self._map[k], "text": v} for k, v in self._text.items()} + + def unpackData(self, data: dict) -> None: + """Unpack text entries from a dictionary.""" + self.clear() + if not isinstance(data, dict): + raise ValueError("textEntry is not a dict") + + for key, entry in data.items(): + if not isinstance(key, str): + raise ValueError("textEntry key must be a string") + if not isinstance(entry, dict): + raise ValueError("textEntry entry is not a dict") + + handle = entry.get("handle") + text = entry.get("text") + if not isHandle(handle): + raise ValueError("textEntry handle must be a handle") + if not isinstance(text, str): + raise ValueError("textEntry text is not a string") + + self.add(key, handle, text) + + return + +# END Class TextEntry + + # =============================================================================================== # # Text Processing Functions # =============================================================================================== # CLASSIFIERS = { "synopsis": nwComment.SYNOPSIS, - "summary": nwComment.SUMMARY, "short": nwComment.SHORT, "note": nwComment.NOTE, + "footnote": nwComment.FOOTNOTE, } -TERMS = ["note"] +TERMS = ["note", "footnote"] def processComment(text: str) -> tuple[nwComment, str, str, int, int]: diff --git a/novelwriter/enum.py b/novelwriter/enum.py index 703df55a..3f630165 100644 --- a/novelwriter/enum.py +++ b/novelwriter/enum.py @@ -66,9 +66,9 @@ class nwComment(Enum): PLAIN = 0 SYNOPSIS = 1 - SUMMARY = 2 - SHORT = 3 - NOTE = 4 + SHORT = 2 + NOTE = 3 + FOOTNOTE = 4 # END Enum nwComment diff --git a/tests/test_core/test_core_index.py b/tests/test_core/test_core_index.py index e637ee95..8a4c9db1 100644 --- a/tests/test_core/test_core_index.py +++ b/tests/test_core/test_core_index.py @@ -1328,8 +1328,8 @@ def testCoreIndex_processComment(): assert processComment("% Hi.You:There") == (nwComment.PLAIN, "", "Hi.You:There", 0, 0) # Check Non-Term - assert processComment("%summary: Hi") == (nwComment.SUMMARY, "", "Hi", 0, 9) - assert processComment("%summary.term: Hi") == (nwComment.SUMMARY, "ERR", "Hi", 9, 14) + assert processComment("%short: Hi") == (nwComment.SHORT, "", "Hi", 0, 7) + assert processComment("%short.term: Hi") == (nwComment.SHORT, "ERR", "Hi", 7, 12) # Check Term assert processComment("%note: Hi") == (nwComment.NOTE, "", "Hi", 0, 6) @@ -1350,7 +1350,7 @@ def testCoreIndex_processComment(): # Check Classifiers assert processComment("%short: Hi") == (nwComment.SHORT, "", "Hi", 0, 7) assert processComment("%synopsis: Hi") == (nwComment.SYNOPSIS, "", "Hi", 0, 10) - assert processComment("%summary: Hi") == (nwComment.SUMMARY, "", "Hi", 0, 9) assert processComment("%note.term: Hi") == (nwComment.NOTE, "term", "Hi", 6, 11) + assert processComment("%footnote.term: Hi") == (nwComment.FOOTNOTE, "term", "Hi", 10, 15) # END Test testCoreIndex_processComment