Move index data classes to a new file

This commit is contained in:
Veronica Berglyd Olsen
2025-02-22 14:40:48 +01:00
parent 655c9d9439
commit 1fa7e08b17
5 changed files with 363 additions and 328 deletions
+4 -323
View File
@@ -31,16 +31,15 @@ import json
import logging
import random
from collections.abc import ItemsView, Iterable, Sequence
from collections.abc import ItemsView, Iterable
from pathlib import Path
from time import time
from typing import TYPE_CHECKING, Literal
from typing import TYPE_CHECKING
from novelwriter import SHARED
from novelwriter.common import (
checkInt, isHandle, isItemClass, isListInstance, isTitleTag, jsonEncode
)
from novelwriter.common import isHandle, isItemClass, isTitleTag, jsonEncode
from novelwriter.constants import nwFiles, nwKeyWords, nwStyles
from novelwriter.core.indexdata import NOTE_TYPES, TT_NONE, IndexHeading, IndexNode, T_NoteTypes
from novelwriter.enum import nwComment, nwItemClass, nwItemLayout, nwItemType
from novelwriter.error import logException
from novelwriter.text.comments import processComment
@@ -52,12 +51,8 @@ if TYPE_CHECKING: # pragma: no cover
logger = logging.getLogger(__name__)
T_NoteTypes = Literal["footnotes", "comments"]
TT_NONE = "T0000" # Default title key
MAX_RETRY = 1000 # Key generator recursion limit
KEY_SOURCE = "0123456789bcdfghjklmnpqrstvwxz"
NOTE_TYPES: list[T_NoteTypes] = ["footnotes", "comments"]
class NWIndex:
@@ -1009,317 +1004,3 @@ class ItemIndex:
self._items[tHandle] = tItem
return
class IndexNode:
"""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
must be reset each time the item is re-indexed.
"""
__slots__ = ("_handle", "_item", "_headings", "_count", "_notes")
def __init__(self, tHandle: str, nwItem: NWItem) -> None:
self._handle = tHandle
self._item = nwItem
self._headings: dict[str, IndexHeading] = {TT_NONE: IndexHeading(TT_NONE)}
self._notes: dict[str, set[str]] = {}
self._count = 0
return
def __repr__(self) -> str:
return f"<IndexNode handle='{self._handle}'>"
def __len__(self) -> int:
return len(self._headings)
def __getitem__(self, sTitle: str) -> IndexHeading | None:
return self._headings.get(sTitle, None)
def __contains__(self, sTitle: str) -> bool:
return sTitle in self._headings
##
# Properties
##
@property
def handle(self) -> str:
"""Return the item handle of the index item."""
return self._handle
@property
def item(self) -> NWItem:
"""Return the project item of the index item."""
return self._item
##
# Setters
##
def addHeading(self, tHeading: IndexHeading) -> None:
"""Add a heading to the item. Also remove the placeholder entry
if it exists.
"""
if TT_NONE in self._headings:
self._headings.pop(TT_NONE)
self._headings[tHeading.key] = tHeading
return
def setHeadingCounts(self, sTitle: str, cCount: int, wCount: int, pCount: int) -> None:
"""Set the character, word and paragraph count of a heading."""
if sTitle in self._headings:
self._headings[sTitle].setCounts([cCount, wCount, pCount])
return
def setHeadingSynopsis(self, sTitle: str, text: str) -> None:
"""Set the synopsis text of a heading."""
if sTitle in self._headings:
self._headings[sTitle].setSynopsis(text)
return
def setHeadingTag(self, sTitle: str, tagKey: str) -> None:
"""Set the tag of a heading."""
if sTitle in self._headings:
self._headings[sTitle].setTag(tagKey)
return
def addHeadingRef(self, sTitle: str, tagKeys: list[str], refType: str) -> None:
"""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)
return
def addNoteKey(self, style: T_NoteTypes, key: str) -> None:
"""Add a note key to the index."""
if style not in self._notes:
self._notes[style] = set()
self._notes[style].add(key)
return
##
# Data Methods
##
def items(self) -> ItemsView[str, IndexHeading]:
"""Return IndexHeading items."""
return self._headings.items()
def headings(self) -> list[str]:
"""Return heading keys in sorted order."""
return sorted(self._headings.keys())
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) -> str:
"""Return the next heading key to be used."""
self._count += 1
return f"T{self._count:04d}"
def noteKeys(self, style: T_NoteTypes) -> set[str]:
"""Return a set of all note keys."""
return self._notes.get(style, set())
##
# Pack/Unpack
##
def packData(self) -> dict:
"""Pack the indexed item's data into a dictionary."""
data = {}
for sTitle, hItem in self._headings.items():
data[sTitle] = hItem.packData()
if self._notes:
data["document"] = {style: list(keys) for style, keys in self._notes.items()}
return data
def unpackData(self, data: dict) -> None:
"""Unpack an item entry from the data."""
for key, entry in data.items():
if isTitleTag(key):
heading = IndexHeading(key)
heading.unpackData(entry)
self.addHeading(heading)
elif key == "document":
for style, keys in entry.items():
if style not in NOTE_TYPES:
raise ValueError("The notes style is invalid")
if not isListInstance(keys, str):
raise ValueError("The notes keys must be a list of strings")
self._notes[style] = set(keys)
else:
raise ValueError("The itemIndex contains an invalid title key")
return
class IndexHeading:
"""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 the heading.
"""
__slots__ = (
"_key", "_line", "_level", "_title", "_counts",
"_tag", "_refs", "_comments",
)
def __init__(self, key: str, line: int = 0, level: str = "H0", title: str = "") -> None:
self._key = key
self._line = line
self._level = level
self._title = title
self._counts: tuple[int, int, int] = (0, 0, 0)
self._tag = ""
self._refs: dict[str, set[str]] = {}
self._comments: dict[str, str] = {}
return
def __repr__(self) -> str:
return f"<IndexHeading key='{self._key}'>"
##
# Properties
##
@property
def key(self) -> str:
return self._key
@property
def line(self) -> int:
return self._line
@property
def level(self) -> str:
return self._level
@property
def title(self) -> str:
return self._title
@property
def charCount(self) -> int:
return self._counts[0]
@property
def wordCount(self) -> int:
return self._counts[1]
@property
def paraCount(self) -> int:
return self._counts[2]
@property
def synopsis(self) -> str:
return self._comments.get("summary", "")
@property
def tag(self) -> str:
return self._tag
@property
def references(self) -> dict[str, set[str]]:
return self._refs
##
# Setters
##
def setLevel(self, level: str) -> None:
"""Set the level of the heading if it's a valid value."""
if level in nwStyles.H_VALID:
self._level = level
return
def setLine(self, line: int) -> None:
"""Set the line number of a heading."""
self._line = max(0, checkInt(line, 0))
return
def setCounts(self, counts: Sequence[int]) -> None:
"""Set the character, word and paragraph count. Make sure the
value is an integer and is not smaller than 0.
"""
if len(counts) == 3:
self._counts = (
max(0, checkInt(counts[0], 0)),
max(0, checkInt(counts[1], 0)),
max(0, checkInt(counts[2], 0)),
)
return
def setSynopsis(self, text: str) -> None:
"""Set the synopsis text and make sure it is a string."""
self._comments["summary"] = str(text)
return
def setTag(self, tagKey: str) -> None:
"""Set the tag for references, and make sure it is a string."""
self._tag = str(tagKey).lower()
return
def addReference(self, tagKey: str, refType: str) -> None:
"""Add a record of a reference tag, and what keyword types it is
associated with.
"""
if refType in nwKeyWords.VALID_KEYS:
tagKey = tagKey.lower()
if tagKey not in self._refs:
self._refs[tagKey] = set()
self._refs[tagKey].add(refType)
return
##
# Data Methods
##
def packData(self) -> dict:
"""Pack the values into a dictionary for saving to cache."""
data = {}
data["meta"] = {
"level": self._level,
"title": self._title,
"line": self._line,
"tag": self._tag,
"counts": self._counts,
}
if self._refs:
data["refs"] = {k: ",".join(sorted(list(v))) for k, v in self._refs.items()}
if self._comments:
data.update(self._comments)
return data
def unpackData(self, data: dict) -> None:
"""Unpack a heading entry from a dictionary."""
for key, entry in data.items():
if key == "meta":
self.setLevel(entry.get("level", "H0"))
self._title = str(entry.get("title", ""))
self._tag = str(entry.get("tag", ""))
self.setLine(entry.get("line", 0))
self.setCounts(entry.get("counts", [0, 0, 0]))
elif key == "refs":
for key, types in entry.items():
if not isinstance(key, str):
raise ValueError("itemIndex reference key must be a string")
if not isinstance(types, str):
raise ValueError("itemIndex reference type must be a string")
for refType in types.split(","):
if refType in nwKeyWords.VALID_KEYS:
self.addReference(key, refType)
else:
raise ValueError("The itemIndex contains an invalid reference type")
elif key == "summary" or key.startswith("story"):
self._comments[str(key)] = str(entry)
else:
raise KeyError("Unknown key in itemIndex")
return
+354
View File
@@ -0,0 +1,354 @@
"""
novelWriter Project Index Data
================================
File History:
Created: 2022-05-28 [2.0rc1] IndexNode
Created: 2022-05-28 [2.0rc1] IndexHeading
This file is a part of novelWriter
Copyright (C) 2022 Veronica Berglyd Olsen and novelWriter contributors
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 <https://www.gnu.org/licenses/>.
"""
from __future__ import annotations
import logging
from collections.abc import ItemsView, Sequence
from typing import TYPE_CHECKING, Literal
from novelwriter.common import checkInt, isListInstance, isTitleTag
from novelwriter.constants import nwKeyWords, nwStyles
if TYPE_CHECKING: # pragma: no cover
from novelwriter.core.item import NWItem
logger = logging.getLogger(__name__)
T_NoteTypes = Literal["footnotes", "comments"]
TT_NONE = "T0000" # Default title key
NOTE_TYPES: list[T_NoteTypes] = ["footnotes", "comments"]
class IndexNode:
"""Core: Single Index Item Node 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
must be reset each time the item is re-indexed.
"""
__slots__ = ("_handle", "_item", "_headings", "_count", "_notes")
def __init__(self, tHandle: str, nwItem: NWItem) -> None:
self._handle = tHandle
self._item = nwItem
self._headings: dict[str, IndexHeading] = {TT_NONE: IndexHeading(TT_NONE)}
self._notes: dict[str, set[str]] = {}
self._count = 0
return
def __repr__(self) -> str:
return f"<IndexNode handle='{self._handle}'>"
def __len__(self) -> int:
return len(self._headings)
def __getitem__(self, sTitle: str) -> IndexHeading | None:
return self._headings.get(sTitle, None)
def __contains__(self, sTitle: str) -> bool:
return sTitle in self._headings
##
# Properties
##
@property
def handle(self) -> str:
"""Return the item handle of the index item."""
return self._handle
@property
def item(self) -> NWItem:
"""Return the project item of the index item."""
return self._item
##
# Setters
##
def addHeading(self, tHeading: IndexHeading) -> None:
"""Add a heading to the item. Also remove the placeholder entry
if it exists.
"""
if TT_NONE in self._headings:
self._headings.pop(TT_NONE)
self._headings[tHeading.key] = tHeading
return
def setHeadingCounts(self, sTitle: str, cCount: int, wCount: int, pCount: int) -> None:
"""Set the character, word and paragraph count of a heading."""
if sTitle in self._headings:
self._headings[sTitle].setCounts([cCount, wCount, pCount])
return
def setHeadingSynopsis(self, sTitle: str, text: str) -> None:
"""Set the synopsis text of a heading."""
if sTitle in self._headings:
self._headings[sTitle].setSynopsis(text)
return
def setHeadingTag(self, sTitle: str, tagKey: str) -> None:
"""Set the tag of a heading."""
if sTitle in self._headings:
self._headings[sTitle].setTag(tagKey)
return
def addHeadingRef(self, sTitle: str, tagKeys: list[str], refType: str) -> None:
"""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)
return
def addNoteKey(self, style: T_NoteTypes, key: str) -> None:
"""Add a note key to the index."""
if style not in self._notes:
self._notes[style] = set()
self._notes[style].add(key)
return
##
# Data Methods
##
def items(self) -> ItemsView[str, IndexHeading]:
"""Return IndexHeading items."""
return self._headings.items()
def headings(self) -> list[str]:
"""Return heading keys in sorted order."""
return sorted(self._headings.keys())
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) -> str:
"""Return the next heading key to be used."""
self._count += 1
return f"T{self._count:04d}"
def noteKeys(self, style: T_NoteTypes) -> set[str]:
"""Return a set of all note keys."""
return self._notes.get(style, set())
##
# Pack/Unpack
##
def packData(self) -> dict:
"""Pack the indexed item's data into a dictionary."""
data = {}
for sTitle, hItem in self._headings.items():
data[sTitle] = hItem.packData()
if self._notes:
data["document"] = {style: list(keys) for style, keys in self._notes.items()}
return data
def unpackData(self, data: dict) -> None:
"""Unpack an item entry from the data."""
for key, entry in data.items():
if isTitleTag(key):
heading = IndexHeading(key)
heading.unpackData(entry)
self.addHeading(heading)
elif key == "document":
for style, keys in entry.items():
if style not in NOTE_TYPES:
raise ValueError("The notes style is invalid")
if not isListInstance(keys, str):
raise ValueError("The notes keys must be a list of strings")
self._notes[style] = set(keys)
else:
raise ValueError("The itemIndex contains an invalid title key")
return
class IndexHeading:
"""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 the heading.
"""
__slots__ = ("_key", "_line", "_level", "_title", "_counts", "_tag", "_refs", "_comments")
def __init__(self, key: str, line: int = 0, level: str = "H0", title: str = "") -> None:
self._key = key
self._line = line
self._level = level
self._title = title
self._counts: tuple[int, int, int] = (0, 0, 0)
self._tag = ""
self._refs: dict[str, set[str]] = {}
self._comments: dict[str, str] = {}
return
def __repr__(self) -> str:
return f"<IndexHeading key='{self._key}'>"
##
# Properties
##
@property
def key(self) -> str:
return self._key
@property
def line(self) -> int:
return self._line
@property
def level(self) -> str:
return self._level
@property
def title(self) -> str:
return self._title
@property
def charCount(self) -> int:
return self._counts[0]
@property
def wordCount(self) -> int:
return self._counts[1]
@property
def paraCount(self) -> int:
return self._counts[2]
@property
def synopsis(self) -> str:
return self._comments.get("summary", "")
@property
def tag(self) -> str:
return self._tag
@property
def references(self) -> dict[str, set[str]]:
return self._refs
##
# Setters
##
def setLevel(self, level: str) -> None:
"""Set the level of the heading if it's a valid value."""
if level in nwStyles.H_VALID:
self._level = level
return
def setLine(self, line: int) -> None:
"""Set the line number of a heading."""
self._line = max(0, checkInt(line, 0))
return
def setCounts(self, counts: Sequence[int]) -> None:
"""Set the character, word and paragraph count. Make sure the
value is an integer and is not smaller than 0.
"""
if len(counts) == 3:
self._counts = (
max(0, checkInt(counts[0], 0)),
max(0, checkInt(counts[1], 0)),
max(0, checkInt(counts[2], 0)),
)
return
def setSynopsis(self, text: str) -> None:
"""Set the synopsis text and make sure it is a string."""
self._comments["summary"] = str(text)
return
def setTag(self, tagKey: str) -> None:
"""Set the tag for references, and make sure it is a string."""
self._tag = str(tagKey).lower()
return
def addReference(self, tagKey: str, refType: str) -> None:
"""Add a record of a reference tag, and what keyword types it is
associated with.
"""
if refType in nwKeyWords.VALID_KEYS:
tagKey = tagKey.lower()
if tagKey not in self._refs:
self._refs[tagKey] = set()
self._refs[tagKey].add(refType)
return
##
# Data Methods
##
def packData(self) -> dict:
"""Pack the values into a dictionary for saving to cache."""
data = {}
data["meta"] = {
"level": self._level,
"title": self._title,
"line": self._line,
"tag": self._tag,
"counts": self._counts,
}
if self._refs:
data["refs"] = {k: ",".join(sorted(list(v))) for k, v in self._refs.items()}
if self._comments:
data.update(self._comments)
return data
def unpackData(self, data: dict) -> None:
"""Unpack a heading entry from a dictionary."""
for key, entry in data.items():
if key == "meta":
self.setLevel(entry.get("level", "H0"))
self._title = str(entry.get("title", ""))
self._tag = str(entry.get("tag", ""))
self.setLine(entry.get("line", 0))
self.setCounts(entry.get("counts", [0, 0, 0]))
elif key == "refs":
for key, types in entry.items():
if not isinstance(key, str):
raise ValueError("itemIndex reference key must be a string")
if not isinstance(types, str):
raise ValueError("itemIndex reference type must be a string")
for refType in types.split(","):
if refType in nwKeyWords.VALID_KEYS:
self.addReference(key, refType)
else:
raise ValueError("The itemIndex contains an invalid reference type")
elif key == "summary" or key.startswith("story"):
self._comments[str(key)] = str(entry)
else:
raise KeyError("Unknown key in itemIndex")
return
+1 -1
View File
@@ -36,7 +36,7 @@ from PyQt6.QtWidgets import (
from novelwriter import SHARED
from novelwriter.common import checkInt, qtAddAction
from novelwriter.constants import nwLabels, nwLists, nwStyles, trConst
from novelwriter.core.index import IndexHeading, IndexNode
from novelwriter.core.indexdata import IndexHeading, IndexNode
from novelwriter.enum import nwChange, nwDocMode, nwItemClass
from novelwriter.extensions.modified import NIconToolButton
from novelwriter.gui.theme import STYLES_FLAT_TABS, STYLES_MIN_TOOLBUTTON
+1 -1
View File
@@ -40,7 +40,7 @@ from PyQt6.QtWidgets import (
from novelwriter import CONFIG, SHARED
from novelwriter.common import minmax, qtAddAction, qtAddMenu, qtLambda
from novelwriter.constants import nwKeyWords, nwLabels, nwStyles, trConst
from novelwriter.core.index import IndexHeading
from novelwriter.core.indexdata import IndexHeading
from novelwriter.enum import nwChange, nwDocMode, nwItemClass, nwOutline
from novelwriter.extensions.modified import NIconToolButton
from novelwriter.extensions.novelselector import NovelSelector
+3 -3
View File
@@ -1,9 +1,9 @@
"""
novelWriter NWIndex Class Tester
==================================
novelWriter Text Comment Tester
=================================
This file is a part of novelWriter
Copyright (C) 2020 Veronica Berglyd Olsen and novelWriter contributors
Copyright (C) 2025 Veronica Berglyd Olsen and novelWriter contributors
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