Update index data model (#2255)

This commit is contained in:
Veronica Berglyd Olsen
2025-02-22 16:07:15 +01:00
committed by GitHub
13 changed files with 935 additions and 656 deletions
+19 -405
View File
@@ -3,9 +3,7 @@ novelWriter Project Index
===========================
File History:
Created: 2019-05-27 [0.1.4] NWIndex
Created: 2022-05-28 [2.0rc1] IndexItem
Created: 2022-05-28 [2.0rc1] IndexHeading
Created: 2019-05-27 [0.1.4] Index
Created: 2022-05-29 [2.0rc1] TagsIndex
Created: 2022-05-29 [2.0rc1] ItemIndex
@@ -34,15 +32,15 @@ import random
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
from novelwriter.text.counting import standardCounter
if TYPE_CHECKING: # pragma: no cover
@@ -51,15 +49,11 @@ 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:
class Index:
"""Core: Project Index
This class holds the entire index for a given project. The index
@@ -71,8 +65,8 @@ class NWIndex:
this data is set directly by the indexer class in the NWItem object.
The primary index data is contained in a single instance of the
ItemIndex class. This object contains an IndexItem representing each
NWItem of the project. Each IndexItem holds an IndexHeading object
ItemIndex class. This object contains an IndexNode representing each
NWItem of the project. Each IndexNode holds an IndexHeading object
for each heading of the item's text.
A reverse index of all tags is contained in a single instance of the
@@ -102,7 +96,7 @@ class NWIndex:
return
def __repr__(self) -> str:
return f"<NWIndex project='{self._project.data.name}'>"
return f"<Index project='{self._project.data.name}'>"
##
# Properties
@@ -522,7 +516,7 @@ class NWIndex:
# Extract Data
##
def getItemData(self, tHandle: str) -> IndexItem | None:
def getItemData(self, tHandle: str) -> IndexNode | None:
"""Get the index data for a given item."""
return self._itemIndex[tHandle]
@@ -571,7 +565,7 @@ class NWIndex:
def getHandleHeaderCount(self, tHandle: str) -> int:
"""Get the number of headers in an item."""
tItem = self._itemIndex[tHandle]
if isinstance(tItem, IndexItem):
if isinstance(tItem, IndexNode):
return len(tItem)
return 0
@@ -683,7 +677,7 @@ class NWIndex:
def getTagsData(
self, activeOnly: bool = True
) -> Iterable[tuple[str, str, str, IndexItem | None, IndexHeading | None]]:
) -> Iterable[tuple[str, str, str, IndexNode | None, IndexHeading | None]]:
"""Return all known tags."""
for tag, data in self._tagsIndex.items():
iItem = self._itemIndex[data.get("handle")]
@@ -692,7 +686,7 @@ class NWIndex:
yield tag, data.get("name", ""), data.get("class", ""), iItem, hItem
return
def getSingleTag(self, tagKey: str) -> tuple[str, str, IndexItem | None, IndexHeading | None]:
def getSingleTag(self, tagKey: str) -> tuple[str, str, IndexNode | None, IndexHeading | None]:
"""Return tag data for a specific tag."""
tName = self._tagsIndex.tagName(tagKey)
tClass = self._tagsIndex.tagClass(tagKey)
@@ -840,7 +834,7 @@ class ItemIndex:
A wrapper object holding the indexed items. This is a wrapper
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
item is stored in an IndexNode object, which again holds an
IndexHeading object for each heading of the text.
"""
@@ -848,7 +842,7 @@ class ItemIndex:
def __init__(self, project: NWProject) -> None:
self._project = project
self._items: dict[str, IndexItem] = {}
self._items: dict[str, IndexNode] = {}
return
def __contains__(self, tHandle: str) -> bool:
@@ -858,7 +852,7 @@ class ItemIndex:
self._items.pop(tHandle, None)
return
def __getitem__(self, tHandle: str) -> IndexItem | None:
def __getitem__(self, tHandle: str) -> IndexNode | None:
return self._items.get(tHandle, None)
##
@@ -874,7 +868,7 @@ class ItemIndex:
"""Add a new item to the index. This will overwrite the item if
it already exists.
"""
self._items[tHandle] = IndexItem(tHandle, nwItem)
self._items[tHandle] = IndexNode(tHandle, nwItem)
return
def allItemTags(self, tHandle: str) -> list[str]:
@@ -991,7 +985,7 @@ class ItemIndex:
def unpackData(self, data: dict) -> None:
"""Iterate through the itemIndex loaded from cache and check
that it's valid. This will raise errors if there is a problem.
that it's valid. This will raise errors if there are problems.
"""
self._items = {}
if not isinstance(data, dict):
@@ -1003,388 +997,8 @@ class ItemIndex:
nwItem = self._project.tree[tHandle]
if nwItem is not None:
tItem = IndexItem(tHandle, nwItem)
tItem = IndexNode(tHandle, nwItem)
tItem.unpackData(tData)
self._items[tHandle] = tItem
return
class IndexItem:
"""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"<IndexItem 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."""
heads = {}
refs = {}
for sTitle, hItem in self._headings.items():
heads[sTitle] = hItem.packData()
hRefs = hItem.packReferences()
if hRefs:
refs[sTitle] = hRefs
data = {}
data["headings"] = heads
if refs:
data["references"] = refs
if self._notes:
data["notes"] = {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."""
references = data.get("references", {})
for sTitle, hData in data.get("headings", {}).items():
if not isTitleTag(sTitle):
raise ValueError("The itemIndex contains an invalid title key")
tHeading = IndexHeading(sTitle)
tHeading.unpackData(hData)
tHeading.unpackReferences(references.get(sTitle, {}))
self.addHeading(tHeading)
for style, keys in data.get("notes", {}).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)
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", "_charCount", "_wordCount",
"_paraCount", "_synopsis", "_tag", "_refs",
)
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._charCount = 0
self._wordCount = 0
self._paraCount = 0
self._synopsis = ""
self._tag = ""
self._refs: dict[str, set[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._charCount
@property
def wordCount(self) -> int:
return self._wordCount
@property
def paraCount(self) -> int:
return self._paraCount
@property
def synopsis(self) -> str:
return self._synopsis
@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, charCount: int, wordCount: int, paraCount: int) -> None:
"""Set the character, word and paragraph count. Make sure the
value is an integer and is not smaller than 0.
"""
self._charCount = max(0, checkInt(charCount, 0))
self._wordCount = max(0, checkInt(wordCount, 0))
self._paraCount = max(0, checkInt(paraCount, 0))
return
def setSynopsis(self, text: str) -> None:
"""Set the synopsis text and make sure it is a string."""
self._synopsis = 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."""
return {
"level": self._level,
"title": self._title,
"line": self._line,
"tag": self._tag,
"cCount": self._charCount,
"wCount": self._wordCount,
"pCount": self._paraCount,
"synopsis": self._synopsis,
}
def packReferences(self) -> dict[str, str]:
"""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(sorted(list(value))) for key, value in self._refs.items()}
def unpackData(self, data: dict) -> None:
"""Unpack a heading entry from a dictionary."""
self.setLevel(data.get("level", "H0"))
self._title = str(data.get("title", ""))
self._tag = str(data.get("tag", ""))
self.setLine(data.get("line", 0))
self.setCounts(
data.get("cCount", 0),
data.get("wCount", 0),
data.get("pCount", 0),
)
self._synopsis = str(data.get("synopsis", ""))
return
def unpackReferences(self, data: dict) -> None:
"""Unpack a set of references from a dictionary."""
for tagKey, refTypes in data.items():
if not isinstance(tagKey, str):
raise ValueError("itemIndex reference key must be a string")
if not isinstance(refTypes, str):
raise ValueError("itemIndex reference type must be a string")
for refType in refTypes.split(","):
if refType in nwKeyWords.VALID_KEYS:
self.addReference(tagKey, refType)
else:
raise ValueError("The itemIndex contains an invalid reference type")
return
# Text Processing Functions
# =========================
MODIFIERS = {
"synopsis": nwComment.SYNOPSIS,
"short": nwComment.SHORT,
"note": nwComment.NOTE,
"footnote": nwComment.FOOTNOTE,
}
KEY_REQ = {
"synopsis": 0, # Key not allowed
"short": 0, # Key not allowed
"note": 1, # Key optional
"footnote": 2, # Key required
}
def _checkModKey(modifier: str, key: str) -> bool:
"""Check if a modifier and key set are ok."""
if modifier in MODIFIERS:
if key == "":
return KEY_REQ[modifier] < 2
elif key.replace("_", "").isalnum():
return KEY_REQ[modifier] > 0
return False
def processComment(text: str) -> tuple[nwComment, str, str, int, int]:
"""Extract comment style, key and text. Should only be called on
text starting with a %.
"""
if text[:2] == "%~":
return nwComment.IGNORE, "", text[2:].lstrip(), 0, 0
check = text[1:].strip()
start, _, content = check.partition(":")
modifier, _, key = start.rstrip().partition(".")
if content and (clean := modifier.lower()) and _checkModKey(clean, key):
col = text.find(":") + 1
dot = text.find(".", 0, col) + 1
return MODIFIERS[clean], key, content.lstrip(), dot, col
return nwComment.PLAIN, "", check, 0, 0
+356
View File
@@ -0,0 +1,356 @@
"""
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 <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, 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(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__ = ("_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, 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
##
# 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"):
self._comments[str(key)] = str(entry)
else:
raise KeyError("Unknown key in heading entry")
return
+3 -3
View File
@@ -39,7 +39,7 @@ from novelwriter.common import (
makeFileNameSafe, minmax
)
from novelwriter.constants import nwLabels, trConst
from novelwriter.core.index import NWIndex
from novelwriter.core.index import Index
from novelwriter.core.options import OptionState
from novelwriter.core.projectdata import NWProjectData
from novelwriter.core.projectxml import ProjectXMLReader, ProjectXMLWriter, XMLReadState
@@ -75,7 +75,7 @@ class NWProject:
self._storage = NWStorage(self) # The project storage handler
self._data = NWProjectData(self) # The project settings
self._tree = NWTree(self) # The project tree
self._index = NWIndex(self) # The project index
self._index = Index(self) # The project index
self._session = NWSessionLog(self) # The session record
# Project Status
@@ -122,7 +122,7 @@ class NWProject:
return self._tree
@property
def index(self) -> NWIndex:
def index(self) -> Index:
return self._index
@property
+1 -1
View File
@@ -40,12 +40,12 @@ from novelwriter.constants import (
nwHeadFmt, nwKeyWords, nwLabels, nwShortcode, nwStats, nwStyles, nwUnicode,
trConst
)
from novelwriter.core.index import processComment
from novelwriter.core.project import NWProject
from novelwriter.enum import nwComment, nwItemLayout
from novelwriter.formats.shared import (
BlockFmt, BlockTyp, T_Block, T_Formats, T_Note, TextDocumentTheme, TextFmt
)
from novelwriter.text.comments import processComment
from novelwriter.text.patterns import REGEX_PATTERNS, DialogParser
logger = logging.getLogger(__name__)
+1 -1
View File
@@ -38,8 +38,8 @@ from PyQt6.QtGui import (
from novelwriter import CONFIG, SHARED
from novelwriter.common import checkInt
from novelwriter.constants import nwStyles, nwUnicode
from novelwriter.core.index import processComment
from novelwriter.enum import nwComment
from novelwriter.text.comments import processComment
from novelwriter.text.patterns import REGEX_PATTERNS, DialogParser
logger = logging.getLogger(__name__)
+2 -2
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, IndexItem
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
@@ -434,7 +434,7 @@ class _ViewPanelKeyWords(QTreeWidget):
self.clear()
return
def addUpdateEntry(self, tag: str, name: str, iItem: IndexItem, hItem: IndexHeading) -> None:
def addUpdateEntry(self, tag: str, name: str, iItem: IndexNode, hItem: IndexHeading) -> None:
"""Add a new entry, or update an existing one."""
nwItem = iItem.item
impLabel, impIcon = nwItem.getImportStatus()
+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
+68
View File
@@ -0,0 +1,68 @@
"""
novelWriter Text Comments
===========================
File History:
Created: 2023-11-23 [2.2b1]
Moved: 2025-02-09 [2.7b1]
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 <https://www.gnu.org/licenses/>.
"""
from __future__ import annotations
from novelwriter.enum import nwComment
MODIFIERS = {
"synopsis": nwComment.SYNOPSIS,
"short": nwComment.SHORT,
"note": nwComment.NOTE,
"footnote": nwComment.FOOTNOTE,
}
KEY_REQ = {
"synopsis": 0, # Key not allowed
"short": 0, # Key not allowed
"note": 1, # Key optional
"footnote": 2, # Key required
}
def _checkModKey(modifier: str, key: str) -> bool:
"""Check if a modifier and key set are ok."""
if modifier in MODIFIERS:
if key == "":
return KEY_REQ[modifier] < 2
elif key.replace("_", "").isalnum():
return KEY_REQ[modifier] > 0
return False
def processComment(text: str) -> tuple[nwComment, str, str, int, int]:
"""Extract comment style, key and text. Should only be called on
text starting with a %.
"""
if text[:2] == "%~":
return nwComment.IGNORE, "", text[2:].lstrip(), 0, 0
check = text[1:].strip()
start, _, content = check.partition(":")
modifier, _, key = start.rstrip().partition(".")
if content and (clean := modifier.lower()) and _checkModKey(clean, key):
col = text.find(":") + 1
dot = text.find(".", 0, col) + 1
return MODIFIERS[clean], key, content.lstrip(), dot, col
return nwComment.PLAIN, "", check, 0, 0
@@ -6,107 +6,103 @@
},
"novelWriter.itemIndex": {
"7a992350f3eb6": {
"headings": {
"T0001": {"level": "H1", "title": "Lorem Ipsum", "line": 1, "tag": "", "cCount": 259, "wCount": 44, "pCount": 5, "synopsis": ""}
"T0001": {
"meta": {"level": "H1", "title": "Lorem Ipsum", "line": 1, "tag": "", "counts": [259, 44, 5]}
}
},
"8c58a65414c23": {
"headings": {
"T0000": {"level": "H0", "title": "", "line": 0, "tag": "", "cCount": 1058, "wCount": 176, "pCount": 2, "synopsis": ""}
"T0000": {
"meta": {"level": "H0", "title": "", "line": 0, "tag": "", "counts": [1058, 176, 2]}
}
},
"88d59a277361b": {
"headings": {
"T0001": {"level": "H2", "title": "Prologue", "line": 1, "tag": "", "cCount": 605, "wCount": 94, "pCount": 2, "synopsis": "Explanation from the lipsum.com website."}
"T0001": {
"meta": {"level": "H2", "title": "Prologue", "line": 1, "tag": "", "counts": [605, 94, 2]},
"summary": "Explanation from the lipsum.com website."
},
"notes": {
"document": {
"footnotes": ["f9kgf"]
}
},
"db7e733775d4d": {
"headings": {
"T0001": {"level": "H1", "title": "Act One", "line": 1, "tag": "", "cCount": 35, "wCount": 6, "pCount": 1, "synopsis": ""}
"T0001": {
"meta": {"level": "H1", "title": "Act One", "line": 1, "tag": "", "counts": [35, 6, 1]}
}
},
"fb609cd8319dc": {
"headings": {
"T0001": {"level": "H2", "title": "Chapter One", "line": 1, "tag": "", "cCount": 419, "wCount": 67, "pCount": 1, "synopsis": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque at aliquam quam."}
},
"references": {
"T0001": {"bod": "@pov", "main": "@plot", "europe": "@location"}
"T0001": {
"meta": {"level": "H2", "title": "Chapter One", "line": 1, "tag": "", "counts": [419, 67, 1]},
"refs": {"bod": "@pov", "main": "@plot", "europe": "@location"},
"summary": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque at aliquam quam."
}
},
"88243afbe5ed8": {
"headings": {
"T0001": {"level": "H3", "title": "Scene One", "line": 1, "tag": "", "cCount": 1197, "wCount": 174, "pCount": 2, "synopsis": "Aenean ut placerat velit. Etiam laoreet ullamcorper risus, eget lobortis enim scelerisque non. Suspendisse id maximus nunc, et mollis sapien. Curabitur vel semper sapien, non pulvinar dolor. Etiam finibus nisi vel mi molestie consectetur."},
"T0002": {"level": "H4", "title": "Scene One, Section Two", "line": 13, "tag": "", "cCount": 1561, "wCount": 230, "pCount": 2, "synopsis": ""}
"T0001": {
"meta": {"level": "H3", "title": "Scene One", "line": 1, "tag": "", "counts": [1197, 174, 2]},
"refs": {"bod": "@pov", "main": "@plot", "europe": "@location"},
"summary": "Aenean ut placerat velit. Etiam laoreet ullamcorper risus, eget lobortis enim scelerisque non. Suspendisse id maximus nunc, et mollis sapien. Curabitur vel semper sapien, non pulvinar dolor. Etiam finibus nisi vel mi molestie consectetur."
},
"references": {
"T0001": {"bod": "@pov", "main": "@plot", "europe": "@location"}
"T0002": {
"meta": {"level": "H4", "title": "Scene One, Section Two", "line": 13, "tag": "", "counts": [1561, 230, 2]}
}
},
"f96ec11c6a3da": {
"headings": {
"T0001": {"level": "H3", "title": "Scene Two", "line": 1, "tag": "", "cCount": 2034, "wCount": 299, "pCount": 3, "synopsis": "Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Integer sapien nulla, dictum at lacus a, dignissim consectetur dolor. Nunc vel eleifend lacus, eu dapibus orci."},
"T0002": {"level": "H4", "title": "Scene Two, Section Two", "line": 15, "tag": "", "cCount": 2009, "wCount": 301, "pCount": 3, "synopsis": ""}
"T0001": {
"meta": {"level": "H3", "title": "Scene Two", "line": 1, "tag": "", "counts": [2034, 299, 3]},
"refs": {"bod": "@pov", "main": "@plot", "europe": "@location"},
"summary": "Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Integer sapien nulla, dictum at lacus a, dignissim consectetur dolor. Nunc vel eleifend lacus, eu dapibus orci."
},
"references": {
"T0001": {"bod": "@pov", "main": "@plot", "europe": "@location"}
"T0002": {
"meta": {"level": "H4", "title": "Scene Two, Section Two", "line": 15, "tag": "", "counts": [2009, 301, 3]}
}
},
"846352075de7d": {
"headings": {
"T0001": {"level": "H2", "title": "Why do we use it?", "line": 1, "tag": "", "cCount": 631, "wCount": 109, "pCount": 3, "synopsis": ""}
"T0001": {
"meta": {"level": "H2", "title": "Why do we use it?", "line": 1, "tag": "", "counts": [631, 109, 3]}
}
},
"441420a886d82": {
"headings": {
"T0001": {"level": "H2", "title": "Chapter Two", "line": 1, "tag": "", "cCount": 477, "wCount": 70, "pCount": 1, "synopsis": "Curabitur a elit posuere, varius ex et, convallis neque. Phasellus sagittis pharetra sem vitae dapibus. Curabitur varius lorem non pulvinar congue."}
},
"references": {
"T0001": {"bod": "@pov", "main": "@plot", "europe": "@location"}
"T0001": {
"meta": {"level": "H2", "title": "Chapter Two", "line": 1, "tag": "", "counts": [477, 70, 1]},
"refs": {"bod": "@pov", "main": "@plot", "europe": "@location"},
"summary": "Curabitur a elit posuere, varius ex et, convallis neque. Phasellus sagittis pharetra sem vitae dapibus. Curabitur varius lorem non pulvinar congue."
}
},
"eb103bc70c90c": {
"headings": {
"T0001": {"level": "H3", "title": "Scene Three", "line": 1, "tag": "", "cCount": 3006, "wCount": 439, "pCount": 4, "synopsis": "Aenean ut libero ut lectus porttitor rhoncus vel et massa. Nam pretium, nibh et varius vehicula, urna metus blandit eros, euismod pharetra diam diam et libero. Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos."}
},
"references": {
"T0001": {"bod": "@pov", "main": "@plot", "europe": "@location"}
"T0001": {
"meta": {"level": "H3", "title": "Scene Three", "line": 1, "tag": "", "counts": [3006, 439, 4]},
"refs": {"bod": "@pov", "main": "@plot", "europe": "@location"},
"summary": "Aenean ut libero ut lectus porttitor rhoncus vel et massa. Nam pretium, nibh et varius vehicula, urna metus blandit eros, euismod pharetra diam diam et libero. Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos."
}
},
"f8c0562e50f1b": {
"headings": {
"T0001": {"level": "H3", "title": "Scene Four", "line": 1, "tag": "", "cCount": 3839, "wCount": 563, "pCount": 6, "synopsis": "Nam tempor blandit magna laoreet aliquet. Vestibulum auctor posuere leo, ac gravida nisi rhoncus varius. Aenean posuere dolor vitae condimentum volutpat. Donec egestas volutpat risus, quis luctus justo."}
},
"references": {
"T0001": {"bod": "@pov", "main": "@plot", "europe": "@location"}
"T0001": {
"meta": {"level": "H3", "title": "Scene Four", "line": 1, "tag": "", "counts": [3839, 563, 6]},
"refs": {"bod": "@pov", "main": "@plot", "europe": "@location"},
"summary": "Nam tempor blandit magna laoreet aliquet. Vestibulum auctor posuere leo, ac gravida nisi rhoncus varius. Aenean posuere dolor vitae condimentum volutpat. Donec egestas volutpat risus, quis luctus justo."
}
},
"47666c91c7ccf": {
"headings": {
"T0001": {"level": "H3", "title": "Scene Five", "line": 1, "tag": "", "cCount": 3644, "wCount": 543, "pCount": 5, "synopsis": "Praesent eget est porta, dictum ante in, egestas risus. Mauris risus mauris, consequat aliquam mauris et, feugiat iaculis ipsum. Aliquam arcu ipsum, fermentum ut arcu sed, lobortis euismod sem. Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus."}
},
"references": {
"T0001": {"bod": "@pov", "main": "@plot", "europe": "@location"}
"T0001": {
"meta": {"level": "H3", "title": "Scene Five", "line": 1, "tag": "", "counts": [3644, 543, 5]},
"refs": {"bod": "@pov", "main": "@plot", "europe": "@location"},
"summary": "Praesent eget est porta, dictum ante in, egestas risus. Mauris risus mauris, consequat aliquam mauris et, feugiat iaculis ipsum. Aliquam arcu ipsum, fermentum ut arcu sed, lobortis euismod sem. Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus."
}
},
"4c4f28287af27": {
"headings": {
"T0001": {"level": "H1", "title": "Nobody Owens", "line": 1, "tag": "bod", "cCount": 1864, "wCount": 284, "pCount": 3, "synopsis": ""}
},
"references": {
"T0001": {"main": "@plot"}
"T0001": {
"meta": {"level": "H1", "title": "Nobody Owens", "line": 1, "tag": "bod", "counts": [1864, 284, 3]},
"refs": {"main": "@plot"}
}
},
"2426c6f0ca922": {
"headings": {
"T0001": {"level": "H1", "title": "Main Plot", "line": 1, "tag": "main", "cCount": 1369, "wCount": 195, "pCount": 2, "synopsis": ""}
"T0001": {
"meta": {"level": "H1", "title": "Main Plot", "line": 1, "tag": "main", "counts": [1369, 195, 2]}
}
},
"04468803b92e1": {
"headings": {
"T0001": {"level": "H1", "title": "Ancient Europe", "line": 1, "tag": "europe", "cCount": 1770, "wCount": 259, "pCount": 3, "synopsis": ""}
"T0001": {
"meta": {"level": "H1", "title": "Ancient Europe", "line": 1, "tag": "europe", "counts": [1770, 259, 3]}
}
}
}
+14 -187
View File
@@ -1,6 +1,6 @@
"""
novelWriter NWIndex Class Tester
==================================
novelWriter Index Class Tester
================================
This file is a part of novelWriter
Copyright (C) 2020 Veronica Berglyd Olsen and novelWriter contributors
@@ -28,7 +28,7 @@ import pytest
from novelwriter import SHARED
from novelwriter.constants import nwFiles
from novelwriter.core.index import IndexItem, NWIndex, TagsIndex, _checkModKey, processComment
from novelwriter.core.index import Index, IndexNode, TagsIndex
from novelwriter.core.item import NWItem
from novelwriter.core.project import NWProject
from novelwriter.enum import nwComment, nwItemClass, nwItemLayout
@@ -49,8 +49,8 @@ def testCoreIndex_LoadSave(qtbot, monkeypatch, prjLipsum, mockGUI, tstPaths):
project = NWProject()
assert project.openProject(prjLipsum)
index = NWIndex(project)
assert repr(index) == "<NWIndex project='Lorem Ipsum'>"
index = Index(project)
assert repr(index) == "<Index project='Lorem Ipsum'>"
notIndexable = {
"b3643d0f92e32": False, # Novel ROOT
@@ -712,7 +712,7 @@ def testCoreIndex_ExtractData(mockGUI, fncPath, mockRnd):
# ==================================
item = index.getItemData(nHandle)
assert isinstance(item, IndexItem)
assert isinstance(item, IndexNode)
assert item.headings() == ["T0001"]
assert index.getHandleHeaderCount(nHandle) == 1
assert index.getHandleHeaderCount("foo") == 0
@@ -1197,14 +1197,14 @@ def testCoreIndex_ItemIndex(mockGUI, fncPath, mockRnd):
itemIndex.addHeadingRef(cHandle, "T0001", ["Jane", "John"], "@char")
idxData = itemIndex.packData()
assert idxData[cHandle]["headings"]["T0001"] == {
"level": "H2", "line": 1, "title": "Chapter One", "tag": "one",
"cCount": 60, "wCount": 10, "pCount": 2, "synopsis": "In the beginning ...",
assert idxData[cHandle]["T0001"]["meta"] == {
"level": "H2", "line": 1, "title": "Chapter One", "tag": "one", "counts": (60, 10, 2),
}
assert "@pov" in idxData[cHandle]["references"]["T0001"]["jane"]
assert "@focus" in idxData[cHandle]["references"]["T0001"]["jane"]
assert "@char" in idxData[cHandle]["references"]["T0001"]["jane"]
assert "@char" in idxData[cHandle]["references"]["T0001"]["john"]
assert "@pov" in idxData[cHandle]["T0001"]["refs"]["jane"]
assert "@focus" in idxData[cHandle]["T0001"]["refs"]["jane"]
assert "@char" in idxData[cHandle]["T0001"]["refs"]["jane"]
assert "@char" in idxData[cHandle]["T0001"]["refs"]["john"]
assert idxData[cHandle]["T0001"]["summary"] == "In the beginning ..."
# Add the other two files
itemIndex.add(nHandle, project.tree[nHandle]) # type: ignore
@@ -1216,7 +1216,7 @@ def testCoreIndex_ItemIndex(mockGUI, fncPath, mockRnd):
# ====================================
# Check repr strings
assert repr(itemIndex[nHandle]) == f"<IndexItem handle='{nHandle}'>"
assert repr(itemIndex[nHandle]) == f"<IndexNode handle='{nHandle}'>"
assert repr(itemIndex[nHandle]["T0001"]) == "<IndexHeading key='T0001'>" # type: ignore
# Check content of a single item
@@ -1330,176 +1330,3 @@ def testCoreIndex_ItemIndex(mockGUI, fncPath, mockRnd):
with pytest.raises(ValueError) as exc:
itemIndex.unpackData({"stuff": "more stuff"})
assert str(exc.value) == "itemIndex keys must be handles"
# Unknown keys should be skipped
itemIndex.unpackData({C.hInvalid: {}})
assert itemIndex._items == {}
# Known keys can be added, even without data
itemIndex.unpackData({nHandle: {}})
assert nHandle in itemIndex
assert itemIndex[nHandle].handle == nHandle # type: ignore
# Title tags must be valid
with pytest.raises(ValueError) as exc:
itemIndex.unpackData({cHandle: {"headings": {"TTTTTTT": {}}}})
assert str(exc.value) == "The itemIndex contains an invalid title key"
# Reference without a heading should be rejected
itemIndex.unpackData({
cHandle: {
"headings": {"T0001": {}},
"references": {"T0001": {}, "T0002": {}},
}
})
assert "T0001" in itemIndex[cHandle] # type: ignore
assert "T0002" not in itemIndex[cHandle] # type: ignore
itemIndex.clear()
# Tag keys must be strings
with pytest.raises(ValueError) as exc:
itemIndex.unpackData({
cHandle: {
"headings": {"T0001": {}},
"references": {"T0001": {1234: "@pov"}},
"notes": {"footnotes": [], "comments": []},
}
})
assert str(exc.value) == "itemIndex reference key must be a string"
# Type must be strings
with pytest.raises(ValueError) as exc:
itemIndex.unpackData({
cHandle: {
"headings": {"T0001": {}},
"references": {"T0001": {"John": []}},
"notes": {"footnotes": [], "comments": []},
}
})
assert str(exc.value) == "itemIndex reference type must be a string"
# Types must be valid
with pytest.raises(ValueError) as exc:
itemIndex.unpackData({
cHandle: {
"headings": {"T0001": {}},
"references": {"T0001": {"John": "@pov,@char,@stuff"}},
"notes": {"footnotes": [], "comments": []},
}
})
assert str(exc.value) == "The itemIndex contains an invalid reference type"
# Note type must be valid
with pytest.raises(ValueError) as exc:
itemIndex.unpackData({
cHandle: {
"headings": {"T0001": {}},
"references": {"T0001": {"John": "@pov,@char"}},
"notes": {"stuff": [], "comments": []},
}
})
assert str(exc.value) == "The notes style is invalid"
# Note keys must be all strings
with pytest.raises(ValueError) as exc:
itemIndex.unpackData({
cHandle: {
"headings": {"T0001": {}},
"references": {"T0001": {"John": "@pov,@char"}},
"notes": {"footnotes": ["fkey", 1], "comments": []},
}
})
assert str(exc.value) == "The notes keys must be a list of strings"
# This should pass
itemIndex.unpackData({
cHandle: {
"headings": {"T0001": {}},
"references": {"T0001": {"John": "@pov,@char"}},
"notes": {"footnotes": ["fkey"], "comments": ["ckey"]},
}
})
@pytest.mark.core
def testCoreIndex_checkModKey():
"""Test the _checkModKey function."""
# Check Requirements
# Synopsis
assert _checkModKey("synopsis", "") is True
assert _checkModKey("synopsis", "a") is False
# Short
assert _checkModKey("short", "") is True
assert _checkModKey("short", "a") is False
# Note
assert _checkModKey("note", "") is True
assert _checkModKey("note", "a") is True
# Footnote
assert _checkModKey("footnote", "") is False
assert _checkModKey("footnote", "a") is True
# Invalid
assert _checkModKey("stuff", "") is False
assert _checkModKey("stuff", "a") is False
# Check Keys
assert _checkModKey("note", "a") is True
assert _checkModKey("note", "a1") is True
assert _checkModKey("note", "a1.2") is False
assert _checkModKey("note", "a1_2") is True
@pytest.mark.core
def testCoreIndex_processComment():
"""Test the comment processing function."""
# Plain
assert processComment("%Hi") == (nwComment.PLAIN, "", "Hi", 0, 0)
assert processComment("% Hi") == (nwComment.PLAIN, "", "Hi", 0, 0)
assert processComment("% Hi:You") == (nwComment.PLAIN, "", "Hi:You", 0, 0)
assert processComment("% Hi.You:There") == (nwComment.PLAIN, "", "Hi.You:There", 0, 0)
# Ignore
assert processComment("%~Hi") == (nwComment.IGNORE, "", "Hi", 0, 0)
assert processComment("%~ Hi") == (nwComment.IGNORE, "", "Hi", 0, 0)
# Invalid
assert processComment("") == (nwComment.PLAIN, "", "", 0, 0)
# Short : Term not allowed
assert processComment("%short: Hi") == (nwComment.SHORT, "", "Hi", 0, 7)
assert processComment("%short.a: Hi") == (nwComment.PLAIN, "", "short.a: Hi", 0, 0)
# Synopsis : Term not allowed
assert processComment("%synopsis: Hi") == (nwComment.SYNOPSIS, "", "Hi", 0, 10)
assert processComment("%synopsis.a: Hi") == (nwComment.PLAIN, "", "synopsis.a: Hi", 0, 0)
# Note : Term optional
assert processComment("%note: Hi") == (nwComment.NOTE, "", "Hi", 0, 6)
assert processComment("%note.a: Hi") == (nwComment.NOTE, "a", "Hi", 6, 8)
# Footnote : Term required
assert processComment("%footnote: Hi") == (nwComment.PLAIN, "", "footnote: Hi", 0, 0)
assert processComment("%footnote.a: Hi") == (nwComment.FOOTNOTE, "a", "Hi", 10, 12)
# Check Case
assert processComment("%Footnote.a: Hi") == (nwComment.FOOTNOTE, "a", "Hi", 10, 12)
assert processComment("%FOOTNOTE.A: Hi") == (nwComment.FOOTNOTE, "A", "Hi", 10, 12)
assert processComment("%FootNote.A_a: Hi") == (nwComment.FOOTNOTE, "A_a", "Hi", 10, 14)
# Padding without term
assert processComment("%short: Hi") == (nwComment.SHORT, "", "Hi", 0, 7)
assert processComment("% short: Hi") == (nwComment.SHORT, "", "Hi", 0, 8)
assert processComment("% short : Hi") == (nwComment.SHORT, "", "Hi", 0, 10)
assert processComment("% short : Hi") == (nwComment.SHORT, "", "Hi", 0, 12)
assert processComment("% \t short : Hi") == (nwComment.SHORT, "", "Hi", 0, 13)
# Padding with term
assert processComment("%note.term: Hi") == (nwComment.NOTE, "term", "Hi", 6, 11)
assert processComment("% note.term: Hi") == (nwComment.NOTE, "term", "Hi", 7, 12)
assert processComment("% note.term : Hi") == (nwComment.NOTE, "term", "Hi", 7, 13)
assert processComment("% note. term : Hi") == (nwComment.PLAIN, "", "note. term : Hi", 0, 0)
assert processComment("% note . term : Hi") == (nwComment.PLAIN, "", "note . term : Hi", 0, 0)
+308
View File
@@ -0,0 +1,308 @@
"""
novelWriter Index Data Class Tester
====================================
This file is a part of novelWriter
Copyright (C) 2020 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 pytest
from novelwriter.core.indexdata import IndexHeading, IndexNode
from novelwriter.core.item import NWItem
from novelwriter.core.project import NWProject
@pytest.mark.core
def testCoreIndexData_IndexNode(mockGUI):
"""Test the IndexNode class."""
handle = "0123456789abc"
project = NWProject()
item = NWItem(project, handle)
# Defaults
node = IndexNode(handle, item)
assert node.handle == handle
assert node.item is item
assert str(node) == f"<IndexNode handle='{handle}'>"
assert repr(node) == f"<IndexNode handle='{handle}'>"
assert len(node) == 1
assert "T0000" in node # Placeholder heading
# Add a heading
head1 = IndexHeading(node.nextHeading(), line=1, level="H1", title="Heading 1")
head2 = IndexHeading(node.nextHeading(), line=10, level="H2", title="Heading 2")
node.addHeading(head1)
node.addHeading(head2)
assert len(node) == 2
assert "T0000" not in node # Placeholder heading should be gone
assert "T0001" in node
assert "T0002" in node
assert node["T0001"] is head1
assert node["T0002"] is head2
assert node.headings() == ["T0001", "T0002"]
assert dict(node.items()) == {"T0001": head1, "T0002": head2}
# Next heading should be T0003
assert node.nextHeading() == "T0003"
# Set counts
node.setHeadingCounts("T0001", 42, 13, 3)
node.setHeadingCounts("T0002", 84, 26, 6)
assert head1.charCount == 42
assert head1.wordCount == 13
assert head1.paraCount == 3
assert head2.charCount == 84
assert head2.wordCount == 26
assert head2.paraCount == 6
# Set synopsis
node.setHeadingSynopsis("T0001", "The first")
node.setHeadingSynopsis("T0002", "The second")
assert head1.synopsis == "The first"
assert head2.synopsis == "The second"
# Set tags
node.setHeadingTag("T0001", "part1")
node.setHeadingTag("T0002", "part2")
assert head1.tag == "part1"
assert head2.tag == "part2"
assert node.allTags() == ["part1", "part2"]
# Add references
node.addHeadingRef("T0001", ["jane"], "@pov")
node.addHeadingRef("T0001", ["jane", "john"], "@char")
node.addHeadingRef("T0002", ["earth", "space"], "@location")
assert head1.references["jane"] == {"@char", "@pov"}
assert head1.references["john"] == {"@char"}
assert head2.references["earth"] == {"@location"}
assert head2.references["space"] == {"@location"}
# Add note keys
assert node.noteKeys("footnotes") == set()
node.addNoteKey("footnotes", "key1")
node.addNoteKey("footnotes", "key2")
assert node.noteKeys("footnotes") == {"key1", "key2"}
@pytest.mark.core
def testCoreIndexData_IndexNodePackUnpack(mockGUI):
"""Test the pack and unpack methods of the IndexNode class."""
handle = "0123456789abc"
project = NWProject()
item = NWItem(project, handle)
node = IndexNode(handle, item)
# Add some headings and notes
head1 = IndexHeading(node.nextHeading(), line=1, level="H1", title="Heading 1")
head2 = IndexHeading(node.nextHeading(), line=10, level="H2", title="Heading 2")
node.addHeading(head1)
node.addHeading(head2)
node.setHeadingCounts("T0001", 42, 13, 3)
node.setHeadingCounts("T0002", 84, 26, 6)
node.addNoteKey("footnotes", "key1")
node.addNoteKey("footnotes", "key2")
# Check packing
data = node.packData()
assert data["T0001"] == {"meta": {
"level": "H1", "title": "Heading 1", "line": 1, "tag": "", "counts": (42, 13, 3)
}}
assert data["T0002"] == {"meta": {
"level": "H2", "title": "Heading 2", "line": 10, "tag": "", "counts": (84, 26, 6)
}}
assert set(data["document"]["footnotes"]) == {"key1", "key2"}
# Create a new node
new = IndexNode(handle, item)
# Unpack heading one
data = {"T0001": {"meta": {
"level": "H1", "title": "Heading 1", "line": 1, "tag": "", "counts": (42, 13, 3)
}}}
new.unpackData(data)
data = new.packData()
assert data["T0001"] == {"meta": {
"level": "H1", "title": "Heading 1", "line": 1, "tag": "", "counts": (42, 13, 3)
}}
# Unpack invalid key
data = {"stuff": "whatever"}
with pytest.raises(KeyError, match="Index node contains an invalid key"):
new.unpackData(data)
# Unpack invalid document keys
data = {"document": {"whatever": []}}
with pytest.raises(ValueError, match="The notes style is invalid"):
new.unpackData(data)
# Unpack invalid document values
data = {"document": {"footnotes": [1, 2, 3]}}
with pytest.raises(ValueError, match="The notes keys must be a list of strings"):
new.unpackData(data)
# Unpack valid keys
data = {"document": {"footnotes": ["key1", "key2"]}}
new.unpackData(data)
assert set(new.packData()["document"]["footnotes"]) == {"key1", "key2"}
@pytest.mark.core
def testCoreIndexData_IndexHeading():
"""Test the IndexHeading class."""
# Defaults
head = IndexHeading("T0001")
assert str(head) == "<IndexHeading key='T0001'>"
assert repr(head) == "<IndexHeading key='T0001'>"
assert head.key == "T0001"
assert head.line == 0
assert head.level == "H0"
assert head.title == ""
assert head.charCount == 0
assert head.wordCount == 0
assert head.paraCount == 0
assert head.synopsis == ""
assert head.tag == ""
assert head.references == {}
# Set Level
head.setLevel("Stuff") # Invalid
assert head.level == "H0"
head.setLevel("H1") # Valid
assert head.level == "H1"
# Set Line
head.setLine(-1) # Invalid
assert head.line == 0
head.setLine(42) # Valid
assert head.line == 42
# Set Counts
head.setCounts([1, 2]) # Invalid, must be three values
assert head.charCount == 0
assert head.wordCount == 0
assert head.paraCount == 0
head.setCounts([42, 4, 2]) # Valid
assert head.charCount == 42
assert head.wordCount == 4
assert head.paraCount == 2
# Set Summary
head.setSynopsis("In the beginning ...")
assert head.synopsis == "In the beginning ..."
# Set Tag
head.setTag("Stuff")
assert head.tag == "stuff" # Case insensitive
# Add References
head.addReference("Stuff", "@stuff") # Invalid type
assert head.references == {}
head.addReference("Stuff", "@object") # Valid type
assert head.references == {"stuff": {"@object"}}
# Pack Data
assert head.packData() == {
"meta": {"level": "H1", "title": "", "line": 42, "tag": "stuff", "counts": (42, 4, 2)},
"refs": {"stuff": "@object"},
"summary": "In the beginning ...",
}
# Unpack KeyError
with pytest.raises(KeyError, match="Unknown key in heading entry"):
head.unpackData({"stuff": "more stuff"})
# Unpack Comments
head.unpackData({"summary": "How it started ..."})
assert head.synopsis == "How it started ..."
@pytest.mark.core
def testCoreIndexData_IndexHeadingUnpackMeta():
"""Test IndexHeading class meta unpacking."""
# Valid
data = {"meta": {
"level": "H1", "title": "So it Begins", "line": 1, "tag": "begins", "counts": [95, 18, 1]
}}
head = IndexHeading("T0001")
head.unpackData(data)
assert head.level == "H1"
assert head.title == "So it Begins"
assert head.line == 1
assert head.tag == "begins"
assert head.charCount == 95
assert head.wordCount == 18
assert head.paraCount == 1
# Invalid
data = {"meta": {
"level": "H9", "title": None, "line": None, "tag": None, "counts": [42]
}}
head = IndexHeading("T0001")
head.unpackData(data)
assert head.level == "H0"
assert head.title == "None"
assert head.line == 0
assert head.tag == "None"
assert head.charCount == 0
assert head.wordCount == 0
assert head.paraCount == 0
# Empty
data = {"meta": {}}
head = IndexHeading("T0001")
head.unpackData(data)
assert head.level == "H0"
assert head.title == ""
assert head.line == 0
assert head.tag == ""
assert head.charCount == 0
assert head.wordCount == 0
assert head.paraCount == 0
@pytest.mark.core
def testCoreIndexData_IndexHeadingUnpackRefs():
"""Test IndexHeading class refs unpacking."""
# Valid
data = {"refs": {
"jane": "@char,@pov", "john": "@char", "earth": "@location", "space": "@mention,@location"
}}
head = IndexHeading("T0001")
head.unpackData(data)
assert head.references["jane"] == {"@char", "@pov"}
assert head.references["john"] == {"@char"}
assert head.references["earth"] == {"@location"}
assert head.references["space"] == {"@location", "@mention"}
# Invalid key
data = {"refs": {0: "@char,@pov"}}
head = IndexHeading("T0001")
with pytest.raises(ValueError, match="Heading reference key must be a string"):
head.unpackData(data)
# Invalid value
data = {"refs": {"jane": None}}
head = IndexHeading("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("T0001")
with pytest.raises(ValueError, match="Heading reference contains an invalid keyword"):
head.unpackData(data)
+1 -1
View File
@@ -286,7 +286,7 @@ def testCoreProject_Open(monkeypatch, caplog, mockGUI, fncPath, mockRnd):
# Trigger an index rebuild
with monkeypatch.context() as mp:
mp.setattr(ProjectXMLReader, "state", property(lambda *a: XMLReadState.WAS_LEGACY))
mp.setattr("novelwriter.core.index.NWIndex.loadIndex", lambda *a: True)
mp.setattr("novelwriter.core.index.Index.loadIndex", lambda *a: True)
project.index._indexBroken = True
assert project.openProject(fncPath, clearLock=True) is True
assert "The file format of your project is about to be" in SHARED.lastAlert
+110
View File
@@ -0,0 +1,110 @@
"""
novelWriter Text Comment Tester
=================================
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 <https://www.gnu.org/licenses/>.
"""
from __future__ import annotations
import pytest
from novelwriter.enum import nwComment
from novelwriter.text.comments import _checkModKey, processComment
@pytest.mark.core
def testTextComments_checkModKey():
"""Test the _checkModKey function."""
# Check Requirements
# Synopsis
assert _checkModKey("synopsis", "") is True
assert _checkModKey("synopsis", "a") is False
# Short
assert _checkModKey("short", "") is True
assert _checkModKey("short", "a") is False
# Note
assert _checkModKey("note", "") is True
assert _checkModKey("note", "a") is True
# Footnote
assert _checkModKey("footnote", "") is False
assert _checkModKey("footnote", "a") is True
# Invalid
assert _checkModKey("stuff", "") is False
assert _checkModKey("stuff", "a") is False
# Check Keys
assert _checkModKey("note", "a") is True
assert _checkModKey("note", "a1") is True
assert _checkModKey("note", "a1.2") is False
assert _checkModKey("note", "a1_2") is True
@pytest.mark.core
def testTextComments_processComment():
"""Test the comment processing function."""
# Plain
assert processComment("%Hi") == (nwComment.PLAIN, "", "Hi", 0, 0)
assert processComment("% Hi") == (nwComment.PLAIN, "", "Hi", 0, 0)
assert processComment("% Hi:You") == (nwComment.PLAIN, "", "Hi:You", 0, 0)
assert processComment("% Hi.You:There") == (nwComment.PLAIN, "", "Hi.You:There", 0, 0)
# Ignore
assert processComment("%~Hi") == (nwComment.IGNORE, "", "Hi", 0, 0)
assert processComment("%~ Hi") == (nwComment.IGNORE, "", "Hi", 0, 0)
# Invalid
assert processComment("") == (nwComment.PLAIN, "", "", 0, 0)
# Short : Term not allowed
assert processComment("%short: Hi") == (nwComment.SHORT, "", "Hi", 0, 7)
assert processComment("%short.a: Hi") == (nwComment.PLAIN, "", "short.a: Hi", 0, 0)
# Synopsis : Term not allowed
assert processComment("%synopsis: Hi") == (nwComment.SYNOPSIS, "", "Hi", 0, 10)
assert processComment("%synopsis.a: Hi") == (nwComment.PLAIN, "", "synopsis.a: Hi", 0, 0)
# Note : Term optional
assert processComment("%note: Hi") == (nwComment.NOTE, "", "Hi", 0, 6)
assert processComment("%note.a: Hi") == (nwComment.NOTE, "a", "Hi", 6, 8)
# Footnote : Term required
assert processComment("%footnote: Hi") == (nwComment.PLAIN, "", "footnote: Hi", 0, 0)
assert processComment("%footnote.a: Hi") == (nwComment.FOOTNOTE, "a", "Hi", 10, 12)
# Check Case
assert processComment("%Footnote.a: Hi") == (nwComment.FOOTNOTE, "a", "Hi", 10, 12)
assert processComment("%FOOTNOTE.A: Hi") == (nwComment.FOOTNOTE, "A", "Hi", 10, 12)
assert processComment("%FootNote.A_a: Hi") == (nwComment.FOOTNOTE, "A_a", "Hi", 10, 14)
# Padding without term
assert processComment("%short: Hi") == (nwComment.SHORT, "", "Hi", 0, 7)
assert processComment("% short: Hi") == (nwComment.SHORT, "", "Hi", 0, 8)
assert processComment("% short : Hi") == (nwComment.SHORT, "", "Hi", 0, 10)
assert processComment("% short : Hi") == (nwComment.SHORT, "", "Hi", 0, 12)
assert processComment("% \t short : Hi") == (nwComment.SHORT, "", "Hi", 0, 13)
# Padding with term
assert processComment("%note.term: Hi") == (nwComment.NOTE, "term", "Hi", 6, 11)
assert processComment("% note.term: Hi") == (nwComment.NOTE, "term", "Hi", 7, 12)
assert processComment("% note.term : Hi") == (nwComment.NOTE, "term", "Hi", 7, 13)
assert processComment("% note. term : Hi") == (nwComment.PLAIN, "", "note. term : Hi", 0, 0)
assert processComment("% note . term : Hi") == (nwComment.PLAIN, "", "note . term : Hi", 0, 0)