diff --git a/novelwriter/constants.py b/novelwriter/constants.py index 30071606..dc62dbd3 100644 --- a/novelwriter/constants.py +++ b/novelwriter/constants.py @@ -34,7 +34,7 @@ def trConst(tString): return QCoreApplication.translate("Constant", tString) -class nwConst(): +class nwConst: # Date and Time Formats FMT_TSTAMP = "%Y-%m-%d %H:%M:%S" # Default format @@ -48,7 +48,7 @@ class nwConst(): # END Class nwConst -class nwRegEx(): +class nwRegEx: FMT_EI = r"(?" + ## # Properties ## @@ -124,8 +125,7 @@ class NWIndex: logger.debug("Re-indexing item '%s'", tHandle) theDoc = NWDoc(self.theProject, tHandle) - theText = theDoc.readDocument() - self.scanText(tHandle, theText if theText is not None else "") + self.scanText(tHandle, theDoc.readDocument() or "") return True @@ -316,7 +316,7 @@ class NWIndex: return True ## - # Internal Indexers + # Internal Indexer Helpers ## def _indexTitle(self, tHandle, aLine, nTitle): @@ -613,11 +613,13 @@ class NWIndex: # =============================================================================================== # -# Indexer Objects +# The Tags Index Object # =============================================================================================== # class TagsIndex: - """A wrapper class that holds the reverse lookup tags index. + """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. """ def __init__(self): @@ -719,8 +721,16 @@ class TagsIndex: # END Class TagsIndex +# =============================================================================================== # +# The Item Index Objects +# =============================================================================================== # + class ItemIndex: - """A wrapper object holding the indexed items. + """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 + IndexHeading object for each header of the text. """ def __init__(self, theProject): @@ -896,7 +906,8 @@ class IndexItem: """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. + item. The main heading level of the item is also held here since it + must be reset each time the item is re-indexed. """ def __init__(self, tHandle, tItem): @@ -912,7 +923,7 @@ class IndexItem: return def __repr__(self): - return f"" + return f"" ## # Properties @@ -1062,7 +1073,7 @@ class IndexHeading: return def __repr__(self): - return f"" + return f"" ## # Properties @@ -1165,8 +1176,11 @@ class IndexHeading: def packReferences(self): """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 + of a set is not guaranteed. """ - return {key: ",".join(value) for key, value in self._refs.items()} + return {key: ",".join(sorted(list(value))) for key, value in self._refs.items()} def unpackData(self, data): """Unpack a heading entry from a dictionary. @@ -1189,7 +1203,7 @@ class IndexHeading: if not isinstance(tagKey, str): raise ValueError("itemIndex reference key must be a string") if not isinstance(refTypes, str): - raise ValueError("itemIndex reference types must be a string") + raise ValueError("itemIndex reference type must be a string") for refType in refTypes.split(","): if refType in nwKeyWords.VALID_KEYS: self.addReference(tagKey, refType) diff --git a/tests/test_core/test_core_index.py b/tests/test_core/test_core_index.py index b1233275..78361bb6 100644 --- a/tests/test_core/test_core_index.py +++ b/tests/test_core/test_core_index.py @@ -29,7 +29,7 @@ from mock import causeException from tools import buildTestProject, cmpFiles, writeFile from novelwriter.core.project import NWProject -from novelwriter.core.index import NWIndex, countWords +from novelwriter.core.index import NWIndex, countWords, TagsIndex from novelwriter.enum import nwItemClass, nwItemLayout @@ -46,6 +46,8 @@ def testCoreIndex_LoadSave(monkeypatch, nwLipsum, mockGUI, outDir, refDir): assert theProject.openProject(nwLipsum) theIndex = NWIndex(theProject) + assert repr(theIndex) == "" + notIndexable = { "b3643d0f92e32": False, # Novel ROOT "45e6b01ca35c1": False, # Chapter One FOLDER @@ -761,6 +763,166 @@ def testCoreIndex_ExtractData(mockGUI, fncDir, mockRnd): # END Test testCoreIndex_ExtractData +@pytest.mark.core +def testCoreIndex_TagsIndex(): + """Check the TagsIndex class. + """ + tagsIndex = TagsIndex() + assert tagsIndex._tags == {} + + # Expected data + content = { + "Tag1": { + "handle": "0000000000001", + "heading": "T000001", + "class": nwItemClass.NOVEL.name, + }, + "Tag2": { + "handle": "0000000000002", + "heading": "T000002", + "class": nwItemClass.CHARACTER.name, + }, + "Tag3": { + "handle": "0000000000003", + "heading": "T000003", + "class": nwItemClass.PLOT.name, + }, + } + + # Add data + tagsIndex.add("Tag1", "0000000000001", "T000001", nwItemClass.NOVEL) + tagsIndex.add("Tag2", "0000000000002", "T000002", nwItemClass.CHARACTER) + tagsIndex.add("Tag3", "0000000000003", "T000003", nwItemClass.PLOT) + assert tagsIndex._tags == content + + # Get items + assert tagsIndex["Tag1"] == content["Tag1"] + assert tagsIndex["Tag2"] == content["Tag2"] + assert tagsIndex["Tag3"] == content["Tag3"] + assert tagsIndex["Tag4"] is None + + # Contains + assert "Tag1" in tagsIndex + assert "Tag2" in tagsIndex + assert "Tag3" in tagsIndex + assert "Tag4" not in tagsIndex + + # Read back handles + assert tagsIndex.tagHandle("Tag1") == "0000000000001" + assert tagsIndex.tagHandle("Tag2") == "0000000000002" + assert tagsIndex.tagHandle("Tag3") == "0000000000003" + assert tagsIndex.tagHandle("Tag4") is None + + # Read back headings + assert tagsIndex.tagHeading("Tag1") == "T000001" + assert tagsIndex.tagHeading("Tag2") == "T000002" + assert tagsIndex.tagHeading("Tag3") == "T000003" + assert tagsIndex.tagHeading("Tag4") == "T000000" + + # Read back classes + assert tagsIndex.tagClass("Tag1") == nwItemClass.NOVEL.name + assert tagsIndex.tagClass("Tag2") == nwItemClass.CHARACTER.name + assert tagsIndex.tagClass("Tag3") == nwItemClass.PLOT.name + assert tagsIndex.tagClass("Tag4") is None + + # Pack Data + assert tagsIndex.packData() == content + + # Delete the second key and a nomn-existant key + del tagsIndex["Tag2"] + del tagsIndex["Tag4"] + assert "Tag1" in tagsIndex + assert "Tag2" not in tagsIndex + assert "Tag3" in tagsIndex + assert "Tag4" not in tagsIndex + + # Clear and reload + tagsIndex.clear() + assert tagsIndex._tags == {} + assert tagsIndex.packData() == {} + + tagsIndex.unpackData(content) + assert tagsIndex._tags == content + assert tagsIndex.packData() == content + + # Unpack Errors + # ============= + tagsIndex.clear() + + # Invalid data type + with pytest.raises(ValueError): + tagsIndex.unpackData([]) + + # Invalid key + with pytest.raises(ValueError): + tagsIndex.unpackData({ + 1234: { + "handle": "0000000000001", + "heading": "T000001", + "class": "NOVEL", + } + }) + + # Missing handle + with pytest.raises(KeyError): + tagsIndex.unpackData({ + "Tag1": { + "heading": "T000001", + "class": "NOVEL", + } + }) + + # Missing heading + with pytest.raises(KeyError): + tagsIndex.unpackData({ + "Tag1": { + "handle": "0000000000001", + "class": "NOVEL", + } + }) + + # Missing class + with pytest.raises(KeyError): + tagsIndex.unpackData({ + "Tag1": { + "handle": "0000000000001", + "heading": "T000001", + } + }) + + # Invalid handle + with pytest.raises(ValueError): + tagsIndex.unpackData({ + "Tag1": { + "handle": "blablabla", + "heading": "T000001", + "class": "NOVEL", + } + }) + + # Invalid heading + with pytest.raises(ValueError): + tagsIndex.unpackData({ + "Tag1": { + "handle": "0000000000001", + "heading": "blabla", + "class": "NOVEL", + } + }) + + # Invalid class + with pytest.raises(ValueError): + tagsIndex.unpackData({ + "Tag1": { + "handle": "0000000000001", + "heading": "T000001", + "class": "blabla", + } + }) + +# END Test testCoreIndex_TagsIndex + + @pytest.mark.core def testCoreIndex_ItemIndex(mockGUI, fncDir, mockRnd): """Check the ItemIndex class. @@ -780,26 +942,6 @@ def testCoreIndex_ItemIndex(mockGUI, fncDir, mockRnd): assert cHandle not in itemIndex assert sHandle not in itemIndex - # Unpack Data - # =========== - - # Data must be dictionary - with pytest.raises(ValueError): - itemIndex.unpackData("stuff") - - # Keys must be valid handles - with pytest.raises(ValueError): - itemIndex.unpackData({"stuff": "more stuff"}) - - # Unknown keys should be skipped - itemIndex.unpackData({"0000000000000": {}}) - assert itemIndex._items == {} - - # Known keys can be added, even witout data - itemIndex.unpackData({nHandle: {}}) - assert nHandle in itemIndex - itemIndex.clear() - # Add Items # ========= assert cHandle not in itemIndex @@ -820,7 +962,7 @@ def testCoreIndex_ItemIndex(mockGUI, fncDir, mockRnd): # Set the remainig data values itemIndex.setHeadingCounts(cHandle, "T000001", 60, 10, 2) itemIndex.setHeadingSynopsis(cHandle, "T000001", "In the beginning ...") - itemIndex.setHeadingTag(cHandle, "T000001", "One") # Although it isn't allowed to have a tag + itemIndex.setHeadingTag(cHandle, "T000001", "One") itemIndex.addHeadingReferences(cHandle, "T000001", ["Jane"], "@pov") itemIndex.addHeadingReferences(cHandle, "T000001", ["Jane"], "@focus") itemIndex.addHeadingReferences(cHandle, "T000001", ["Jane", "John"], "@char") @@ -842,6 +984,37 @@ def testCoreIndex_ItemIndex(mockGUI, fncDir, mockRnd): itemIndex.addItemHeading(nHandle, "T000001", "H1", "Novel") itemIndex.addItemHeading(sHandle, "T000001", "H3", "Scene One") + # Check Item and Heading Direct Access + # ==================================== + + # Check repr strings + assert repr(itemIndex[nHandle]) == f"" + assert repr(itemIndex[nHandle]["T000001"]) == "" + + # Check content of a single item + assert "T000001" in itemIndex[nHandle] + assert itemIndex[cHandle].allTags() == ["One"] + + # Check the content of a single heading + assert itemIndex[cHandle]["T000001"].key == "T000001" + assert itemIndex[cHandle]["T000001"].level == "H2" + assert itemIndex[cHandle]["T000001"].title == "Chapter One" + assert itemIndex[cHandle]["T000001"].tag == "One" + assert itemIndex[cHandle]["T000001"].charCount == 60 + assert itemIndex[cHandle]["T000001"].wordCount == 10 + assert itemIndex[cHandle]["T000001"].paraCount == 2 + assert itemIndex[cHandle]["T000001"].synopsis == "In the beginning ..." + assert "Jane" in itemIndex[cHandle]["T000001"].references + assert "John" in itemIndex[cHandle]["T000001"].references + + # Check heading level setter + itemIndex[cHandle]["T000001"].setLevel("H3") # Change it + assert itemIndex[cHandle]["T000001"].level == "H3" + itemIndex[cHandle]["T000001"].setLevel("H2") # Set it back + assert itemIndex[cHandle]["T000001"].level == "H2" + itemIndex[cHandle]["T000001"].setLevel("H5") # Invalid level + assert itemIndex[cHandle]["T000001"].level == "H2" + # Data Extraction # =============== @@ -908,6 +1081,82 @@ def testCoreIndex_ItemIndex(mockGUI, fncDir, mockRnd): del itemIndex[uHandle] assert uHandle not in itemIndex + # Unpack Error Handling + # ===================== + + # Pack/unpack should restore state + content = itemIndex.packData() + itemIndex.clear() + itemIndex.unpackData(content) + assert itemIndex.packData() == content + itemIndex.clear() + + # Data must be dictionary + with pytest.raises(ValueError): + itemIndex.unpackData("stuff") + + # Keys must be valid handles + with pytest.raises(ValueError): + itemIndex.unpackData({"stuff": "more stuff"}) + + # Unknown keys should be skipped + itemIndex.unpackData({"0000000000000": {}}) + assert itemIndex._items == {} + + # Known keys can be added, even witout data + itemIndex.unpackData({nHandle: {}}) + assert nHandle in itemIndex + + # Title tags must be valid + with pytest.raises(ValueError): + itemIndex.unpackData({cHandle: {"headings": {"TTTTTTT": {}}}}) + + # Reference without a heading should be rejected + itemIndex.unpackData({ + cHandle: { + "headings": {"T000001": {}}, + "references": {"T000001": {}, "T000002": {}}, + } + }) + assert "T000001" in itemIndex[cHandle] + assert "T000002" not in itemIndex[cHandle] + itemIndex.clear() + + # Tag keys must be strings + with pytest.raises(ValueError): + itemIndex.unpackData({ + cHandle: { + "headings": {"T000001": {}}, + "references": {"T000001": {1234: "@pov"}}, + } + }) + + # Type must be strings + with pytest.raises(ValueError): + itemIndex.unpackData({ + cHandle: { + "headings": {"T000001": {}}, + "references": {"T000001": {"John": []}}, + } + }) + + # Types must be valid + with pytest.raises(ValueError): + itemIndex.unpackData({ + cHandle: { + "headings": {"T000001": {}}, + "references": {"T000001": {"John": "@pov,@char,@stuff"}}, + } + }) + + # This should pass + itemIndex.unpackData({ + cHandle: { + "headings": {"T000001": {}}, + "references": {"T000001": {"John": "@pov,@char"}}, + } + }) + # END Test testCoreIndex_ItemIndex