Add support for reading and caching story keys

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