"""
novelWriter – Project Index Data
================================
File History:
Created: 2022-05-28 [2.0rc1] IndexNode
Created: 2022-05-28 [2.0rc1] IndexHeading
Moved: 2025-02-22 [2.7b1] IndexNode
Moved: 2025-02-22 [2.7b1] IndexHeading
This file is a part of novelWriter
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
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 .
"""
from __future__ import annotations
import logging
from typing import TYPE_CHECKING, Literal
from novelwriter import CONFIG
from novelwriter.common import checkInt, compact, isListInstance, isTitleTag
from novelwriter.constants import nwKeyWords, nwStyles
if TYPE_CHECKING:
from collections.abc import ItemsView, Sequence
from novelwriter.core.index import IndexCache
from novelwriter.core.item import NWItem
from novelwriter.enum import nwComment
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__ = ("_cache", "_count", "_handle", "_headings", "_item", "_notes")
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._cache, TT_NONE)}
self._notes: dict[str, set[str]] = {}
self._count = 0
return
def __repr__(self) -> str:
return f""
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 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.name, key, text)
return
def setHeadingTag(self, sTitle: str, tag: str) -> None:
"""Set the tag of a heading."""
if sTitle in self._headings:
self._headings[sTitle].setTag(tag)
return
def addHeadingRef(self, sTitle: str, tags: list[str], keyword: str) -> None:
"""Add a reference key and all its types to a heading."""
if sTitle in self._headings:
for tag in tags:
self._headings[sTitle].addReference(tag, keyword)
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(self._cache, 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 KeyError("Index node contains an invalid 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__ = (
"_cache", "_comments", "_counts", "_key", "_level", "_line", "_refs",
"_tag", "_title",
)
def __init__(
self, cache: IndexCache, key: str, line: int = 0,
level: str = "H0", title: str = "",
) -> None:
self._cache = cache
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""
##
# 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 mainCount(self) -> int:
return self._counts[0 if CONFIG.useCharCount else 1]
@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 comments(self) -> dict[str, str]:
return self._comments
@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 setComment(self, comment: str, key: str, text: str) -> None:
"""Set the text for a comment and make sure it is a string."""
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)
case "note" if key:
self._cache.note.add(key)
self._comments[f"note.{key}"] = str(text)
return
def setTag(self, tag: str) -> None:
"""Set the tag for references, and make sure it is a string."""
self._tag = str(tag).lower()
return
def addReference(self, tag: str, keyword: str) -> None:
"""Add a record of a reference tag, and what keyword types it is
associated with.
"""
if keyword in nwKeyWords.VALID_KEYS:
tag = tag.lower()
if tag not in self._refs:
self._refs[tag] = set()
self._refs[tag].add(keyword)
return
##
# Getters
##
def getReferences(self) -> dict[str, list[str]]:
"""Extract all tags and references for this heading."""
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._cache.tags.tagName(tag)):
refs[keyword].append(name)
if tag := self._tag:
refs[nwKeyWords.TAG_KEY] = [self._cache.tags.tagName(tag)]
return refs
def getReferencesByKeyword(self, keyword: str) -> list[str]:
"""Extract all references for this heading."""
refs = []
for tag, types in self._refs.items():
if keyword in types and (name := self._cache.tags.tagName(tag)):
refs.append(name)
return refs
##
# 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 tag, value in entry.items():
if not isinstance(tag, str):
raise ValueError("Heading reference key must be a string")
if not isinstance(value, str):
raise ValueError("Heading reference value must be a string")
for keyword in value.split(","):
if keyword in nwKeyWords.VALID_KEYS:
self.addReference(tag, keyword)
else:
raise ValueError("Heading reference contains an invalid keyword")
elif key == "summary" or key.startswith(("story", "note")):
comment, _, kind = str(key).partition(".")
self.setComment(comment, compact(kind), str(entry))
else:
raise KeyError("Unknown key in heading entry")
return