Update index data model (#2255)
This commit is contained in:
+19
-405
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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__)
|
||||
|
||||
@@ -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__)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user