diff --git a/novelwriter/core/index.py b/novelwriter/core/index.py index 6935947b..f320c076 100644 --- a/novelwriter/core/index.py +++ b/novelwriter/core/index.py @@ -893,6 +893,21 @@ class TagsIndex: return +class IndexCache: + """Core: Item Index Lookup Data Class + + A small data class passed between all objects of the Item Index + which provides lookup capabilities and caching for shared data. + """ + + __slots__ = ("tags", "story") + + def __init__(self, tagsIndex: TagsIndex) -> None: + self.tags: TagsIndex = tagsIndex + self.story: set[str] = set() + return + + # The Item Index Objects # ====================== @@ -906,11 +921,11 @@ class ItemIndex: IndexHeading object for each heading of the text. """ - __slots__ = ("_project", "_tags", "_items") + __slots__ = ("_project", "_cache", "_items") def __init__(self, project: NWProject, tagsIndex: TagsIndex) -> None: self._project = project - self._tags = tagsIndex + self._cache = IndexCache(tagsIndex) self._items: dict[str, IndexNode] = {} return @@ -937,7 +952,7 @@ class ItemIndex: """Add a new item to the index. This will overwrite the item if it already exists. """ - self._items[tHandle] = IndexNode(self._tags, tHandle, nwItem) + self._items[tHandle] = IndexNode(self._cache, tHandle, nwItem) return def allItemTags(self, tHandle: str) -> list[str]: @@ -995,7 +1010,7 @@ class ItemIndex: if tHandle in self._items: tItem = self._items[tHandle] sTitle = tItem.nextHeading() - tItem.addHeading(IndexHeading(self._tags, sTitle, lineNo, level, text)) + tItem.addHeading(IndexHeading(self._cache, sTitle, lineNo, level, text)) return sTitle return TT_NONE @@ -1069,7 +1084,7 @@ class ItemIndex: nwItem = self._project.tree[tHandle] if nwItem is not None: - tItem = IndexNode(self._tags, tHandle, nwItem) + tItem = IndexNode(self._cache, tHandle, nwItem) tItem.unpackData(tData) self._items[tHandle] = tItem diff --git a/novelwriter/core/indexdata.py b/novelwriter/core/indexdata.py index 4dbd1954..30b373ba 100644 --- a/novelwriter/core/indexdata.py +++ b/novelwriter/core/indexdata.py @@ -32,12 +32,12 @@ from collections.abc import ItemsView, Sequence from typing import TYPE_CHECKING, Literal from novelwriter import CONFIG -from novelwriter.common import checkInt, isListInstance, isTitleTag +from novelwriter.common import checkInt, compact, isListInstance, isTitleTag from novelwriter.constants import nwKeyWords, nwStyles from novelwriter.enum import nwComment if TYPE_CHECKING: # pragma: no cover - from novelwriter.core.index import TagsIndex + from novelwriter.core.index import IndexCache from novelwriter.core.item import NWItem logger = logging.getLogger(__name__) @@ -58,13 +58,13 @@ class IndexNode: must be reset each time the item is re-indexed. """ - __slots__ = ("_tags", "_handle", "_item", "_headings", "_notes", "_count") + __slots__ = ("_cache", "_handle", "_item", "_headings", "_notes", "_count") - def __init__(self, tagsIndex: TagsIndex, tHandle: str, nwItem: NWItem) -> None: - self._tags = tagsIndex + def __init__(self, cache: IndexCache, tHandle: str, nwItem: NWItem) -> None: + self._cache = cache self._handle = tHandle self._item = nwItem - self._headings: dict[str, IndexHeading] = {TT_NONE: IndexHeading(self._tags, TT_NONE)} + self._headings: dict[str, IndexHeading] = {TT_NONE: IndexHeading(self._cache, TT_NONE)} self._notes: dict[str, set[str]] = {} self._count = 0 return @@ -117,7 +117,7 @@ class IndexNode: def setHeadingComment(self, sTitle: str, comment: nwComment, key: str, text: str) -> None: """Set the comment text of a heading.""" if sTitle in self._headings: - self._headings[sTitle].setComment(comment, key, text) + self._headings[sTitle].setComment(comment.name, key, text) return def setHeadingTag(self, sTitle: str, tag: str) -> None: @@ -182,7 +182,7 @@ class IndexNode: """Unpack an item entry from the data.""" for key, entry in data.items(): if isTitleTag(key): - heading = IndexHeading(self._tags, key) + heading = IndexHeading(self._cache, key) heading.unpackData(entry) self.addHeading(heading) elif key == "document": @@ -206,15 +206,15 @@ class IndexHeading: """ __slots__ = ( - "_tags", "_key", "_line", "_level", "_title", + "_cache", "_key", "_line", "_level", "_title", "_counts", "_tag", "_refs", "_comments", ) def __init__( - self, tagsIndex: TagsIndex, key: str, line: int = 0, + self, cache: IndexCache, key: str, line: int = 0, level: str = "H0", title: str = "", ) -> None: - self._tags = tagsIndex + self._cache = cache self._key = key self._line = line self._level = level @@ -303,10 +303,14 @@ class IndexHeading: ) return - def setComment(self, comment: nwComment, key: str, text: str) -> None: + def setComment(self, comment: str, key: str, text: str) -> None: """Set the text for a comment and make sure it is a string.""" - if comment in (nwComment.SHORT, nwComment.SYNOPSIS): - self._comments["summary"] = str(text) + match comment.lower(): + case "short" | "synopsis" | "summary": + self._comments["summary"] = str(text) + case "story" if key: + self._cache.story.add(key) + self._comments[f"story.{key}"] = str(text) return def setTag(self, tag: str) -> None: @@ -334,7 +338,7 @@ class IndexHeading: refs = {x: [] for x in nwKeyWords.VALID_KEYS} for tag, types in self._refs.items(): for keyword in types: - if keyword in refs and (name := self._tags.tagName(tag)): + if keyword in refs and (name := self._cache.tags.tagName(tag)): refs[keyword].append(name) return refs @@ -342,7 +346,7 @@ class IndexHeading: """Extract all references for this heading.""" refs = [] for tag, types in self._refs.items(): - if keyword in types and (name := self._tags.tagName(tag)): + if keyword in types and (name := self._cache.tags.tagName(tag)): refs.append(name) return refs @@ -387,7 +391,8 @@ class IndexHeading: else: raise ValueError("Heading reference contains an invalid keyword") elif key == "summary" or key.startswith("story"): - self._comments[str(key)] = str(entry) + comment, _, kind = str(key).partition(".") + self.setComment(comment, compact(kind), str(entry)) else: raise KeyError("Unknown key in heading entry") return diff --git a/tests/test_core/test_core_indexdata.py b/tests/test_core/test_core_indexdata.py index 85adc7eb..1563e4fe 100644 --- a/tests/test_core/test_core_indexdata.py +++ b/tests/test_core/test_core_indexdata.py @@ -23,7 +23,7 @@ from __future__ import annotations import pytest from novelwriter import CONFIG -from novelwriter.core.index import TagsIndex +from novelwriter.core.index import IndexCache, TagsIndex from novelwriter.core.indexdata import IndexHeading, IndexNode from novelwriter.core.item import NWItem from novelwriter.core.project import NWProject @@ -36,10 +36,10 @@ def testCoreIndexData_IndexNode(mockGUI): handle = "0123456789abc" project = NWProject() item = NWItem(project, handle) - tags = TagsIndex() + cache = IndexCache(TagsIndex()) # Defaults - node = IndexNode(tags, handle, item) + node = IndexNode(cache, handle, item) assert node.handle == handle assert node.item is item assert str(node) == f"" @@ -48,8 +48,8 @@ def testCoreIndexData_IndexNode(mockGUI): assert "T0000" in node # Placeholder heading # Add a heading - head1 = IndexHeading(tags, node.nextHeading(), line=1, level="H1", title="Heading 1") - head2 = IndexHeading(tags, node.nextHeading(), line=10, level="H2", title="Heading 2") + head1 = IndexHeading(cache, node.nextHeading(), line=1, level="H1", title="Heading 1") + head2 = IndexHeading(cache, node.nextHeading(), line=10, level="H2", title="Heading 2") node.addHeading(head1) node.addHeading(head2) assert len(node) == 2 @@ -80,7 +80,7 @@ def testCoreIndexData_IndexNode(mockGUI): assert head1.synopsis == "The first" assert head2.synopsis == "The second" - # Set tags + # Set cache node.setHeadingTag("T0001", "part1") node.setHeadingTag("T0002", "part2") assert head1.tag == "part1" @@ -109,12 +109,12 @@ def testCoreIndexData_IndexNodePackUnpack(mockGUI): handle = "0123456789abc" project = NWProject() item = NWItem(project, handle) - tags = TagsIndex() - node = IndexNode(tags, handle, item) + cache = IndexCache(TagsIndex()) + node = IndexNode(cache, handle, item) # Add some headings and notes - head1 = IndexHeading(tags, node.nextHeading(), line=1, level="H1", title="Heading 1") - head2 = IndexHeading(tags, node.nextHeading(), line=10, level="H2", title="Heading 2") + head1 = IndexHeading(cache, node.nextHeading(), line=1, level="H1", title="Heading 1") + head2 = IndexHeading(cache, node.nextHeading(), line=10, level="H2", title="Heading 2") node.addHeading(head1) node.addHeading(head2) node.setHeadingCounts("T0001", 42, 13, 3) @@ -133,7 +133,7 @@ def testCoreIndexData_IndexNodePackUnpack(mockGUI): assert set(data["document"]["footnotes"]) == {"key1", "key2"} # Create a new node - new = IndexNode(tags, handle, item) + new = IndexNode(cache, handle, item) # Unpack heading one data = {"T0001": {"meta": { @@ -170,8 +170,8 @@ def testCoreIndexData_IndexNodePackUnpack(mockGUI): def testCoreIndexData_IndexHeading(): """Test the IndexHeading class.""" # Defaults - tags = TagsIndex() - head = IndexHeading(tags, "T0001") + cache = IndexCache(TagsIndex()) + head = IndexHeading(cache, "T0001") assert str(head) == "" assert repr(head) == "" assert head.key == "T0001" @@ -214,7 +214,7 @@ def testCoreIndexData_IndexHeading(): assert head.mainCount == 42 # Set Summary - head.setComment(nwComment.SYNOPSIS, "", "In the beginning ...") + head.setComment(nwComment.SYNOPSIS.name, "", "In the beginning ...") assert head.synopsis == "In the beginning ..." # Set Tag @@ -246,8 +246,8 @@ def testCoreIndexData_IndexHeading(): @pytest.mark.core def testCoreIndexData_IndexHeadingReferences(): """Test the IndexHeading references handling.""" - tags = TagsIndex() - head = IndexHeading(tags, "T0001") + cache = IndexCache(TagsIndex()) + head = IndexHeading(cache, "T0001") # Add some references head.addReference("Jane", "@pov") @@ -273,10 +273,10 @@ def testCoreIndexData_IndexHeadingReferences(): } # Set names - tags.add("Jane", "Jane", "0000000000000", "T00001", "CHARACTER") - tags.add("John", "John", "0000000000000", "T00001", "CHARACTER") - tags.add("Main", "Main", "0000000000000", "T00001", "PLOT") - tags.add("Gun", "Gun", "0000000000000", "T00001", "OBJECT") + cache.tags.add("Jane", "Jane", "0000000000000", "T00001", "CHARACTER") + cache.tags.add("John", "John", "0000000000000", "T00001", "CHARACTER") + cache.tags.add("Main", "Main", "0000000000000", "T00001", "PLOT") + cache.tags.add("Gun", "Gun", "0000000000000", "T00001", "OBJECT") # Now they should be populated assert head.getReferences() == { @@ -312,13 +312,13 @@ def testCoreIndexData_IndexHeadingReferences(): @pytest.mark.core def testCoreIndexData_IndexHeadingUnpackMeta(): """Test IndexHeading class meta unpacking.""" - tags = TagsIndex() + cache = IndexCache(TagsIndex()) # Valid data = {"meta": { "level": "H1", "title": "So it Begins", "line": 1, "tag": "begins", "counts": [95, 18, 1] }} - head = IndexHeading(tags, "T0001") + head = IndexHeading(cache, "T0001") head.unpackData(data) assert head.level == "H1" assert head.title == "So it Begins" @@ -332,7 +332,7 @@ def testCoreIndexData_IndexHeadingUnpackMeta(): data = {"meta": { "level": "H9", "title": None, "line": None, "tag": None, "counts": [42] }} - head = IndexHeading(tags, "T0001") + head = IndexHeading(cache, "T0001") head.unpackData(data) assert head.level == "H0" assert head.title == "None" @@ -344,7 +344,7 @@ def testCoreIndexData_IndexHeadingUnpackMeta(): # Empty data = {"meta": {}} - head = IndexHeading(tags, "T0001") + head = IndexHeading(cache, "T0001") head.unpackData(data) assert head.level == "H0" assert head.title == "" @@ -358,13 +358,13 @@ def testCoreIndexData_IndexHeadingUnpackMeta(): @pytest.mark.core def testCoreIndexData_IndexHeadingUnpackRefs(): """Test IndexHeading class refs unpacking.""" - tags = TagsIndex() + cache = IndexCache(TagsIndex()) # Valid data = {"refs": { "jane": "@char,@pov", "john": "@char", "earth": "@location", "space": "@mention,@location" }} - head = IndexHeading(tags, "T0001") + head = IndexHeading(cache, "T0001") head.unpackData(data) assert head.references["jane"] == {"@char", "@pov"} assert head.references["john"] == {"@char"} @@ -373,18 +373,18 @@ def testCoreIndexData_IndexHeadingUnpackRefs(): # Invalid key data = {"refs": {0: "@char,@pov"}} - head = IndexHeading(tags, "T0001") + head = IndexHeading(cache, "T0001") with pytest.raises(ValueError, match="Heading reference key must be a string"): head.unpackData(data) # Invalid value data = {"refs": {"jane": None}} - head = IndexHeading(tags, "T0001") + head = IndexHeading(cache, "T0001") with pytest.raises(ValueError, match="Heading reference value must be a string"): head.unpackData(data) # Invalid keyword data = {"refs": {"jane": "@char,@pov,@stuff"}} - head = IndexHeading(tags, "T0001") + head = IndexHeading(cache, "T0001") with pytest.raises(ValueError, match="Heading reference contains an invalid keyword"): head.unpackData(data) diff --git a/tests/test_core/test_core_novelmodel.py b/tests/test_core/test_core_novelmodel.py index 8fae210d..b042f5e6 100644 --- a/tests/test_core/test_core_novelmodel.py +++ b/tests/test_core/test_core_novelmodel.py @@ -156,8 +156,8 @@ def testCoreNovelModel_Data(nwGUI, fncPath, mockRnd): model.append(scene) # Add headings to scene - scene.addHeading(IndexHeading(scene._tags, "T0002", 10, "H4", "A Section")) - scene.addHeading(IndexHeading(scene._tags, "T0003", 10, "H4", "Another Section")) + scene.addHeading(IndexHeading(scene._cache, "T0002", 10, "H4", "A Section")) + scene.addHeading(IndexHeading(scene._cache, "T0003", 10, "H4", "Another Section")) assert model.refresh(scene) is True assert [ model.data(model.createIndex(i, 0), Qt.ItemDataRole.DisplayRole)