diff --git a/novelwriter/core/index.py b/novelwriter/core/index.py index 503cb4ea..dc8910aa 100644 --- a/novelwriter/core/index.py +++ b/novelwriter/core/index.py @@ -34,8 +34,9 @@ import logging from time import time from typing import TYPE_CHECKING, ItemsView, Iterable, Iterator from pathlib import Path +from novelwriter import SHARED -from novelwriter.enum import nwItemClass, nwItemType, nwItemLayout +from novelwriter.enum import nwItemClass, nwItemType, nwItemLayout, nwTrinary from novelwriter.error import logException from novelwriter.common import checkInt, isHandle, isItemClass, isTitleTag, jsonEncode from novelwriter.constants import nwFiles, nwKeyWords, nwRegEx, nwUnicode, nwHeaders @@ -254,7 +255,7 @@ class NWIndex: return False # Keep a record of existing tags, and create a new item entry - itemTags = dict.fromkeys(self._itemIndex.allItemTags(tHandle), False) + itemTags = dict.fromkeys(self._itemIndex.allItemTags(tHandle), nwTrinary.NEGATIVE) self._itemIndex.add(tHandle, tItem) # Run word counter for the whole text @@ -289,7 +290,8 @@ class NWIndex: # Internal Indexer Helpers ## - def _scanActive(self, tHandle: str, nwItem: NWItem, text: str, tags: dict) -> None: + def _scanActive(self, tHandle: str, nwItem: NWItem, text: str, + tags: dict[str, nwTrinary]) -> None: """Scan an active document for meta data.""" nTitle = 0 # Line Number of the previous title cTitle = TT_NONE # Tag of the current title @@ -345,10 +347,20 @@ class NWIndex: self._indexWordCounts(tHandle, text, cTitle) # Prune no longer used tags - for tTag, isActive in tags.items(): - if not isActive: - logger.debug("Deleting removed tag '%s'", tTag) + for tTag, tStatus in tags.items(): + added = [] + deleted = [] + if tStatus == nwTrinary.NEGATIVE: + logger.debug("Removed tag '%s'", tTag) del self._tagsIndex[tTag] + deleted.append(tTag) + elif tStatus == nwTrinary.POSITIVE: + logger.debug("Added new tag '%s'", tTag) + added.append(tTag) + else: + logger.debug("Unchanged tag '%s'", tTag) + if added or deleted: + SHARED.indexUpdatedTags(added, deleted) return @@ -385,7 +397,7 @@ class NWIndex: return def _indexKeyword(self, tHandle: str, line: str, sTitle: str, - itemClass: nwItemClass, tags: dict) -> None: + itemClass: nwItemClass, tags: dict[str, nwTrinary]) -> None: """Validate and save the information about a reference to a tag in another file, or the setting of a tag in the file. A record of active tags is updated so that no longer used tags can be @@ -402,9 +414,10 @@ class NWIndex: if tBits[0] == nwKeyWords.TAG_KEY: tagName = tBits[1] + tagKey = tagName.lower() self._tagsIndex.add(tagName, tHandle, sTitle, itemClass) self._itemIndex.setHeadingTag(tHandle, sTitle, tagName) - tags[tagName.lower()] = True + tags[tagKey] = nwTrinary.NEUTRAL if tagKey in tags else nwTrinary.POSITIVE else: self._itemIndex.addHeadingRef(tHandle, sTitle, tBits[1:], tBits[0]) @@ -615,7 +628,7 @@ class NWIndex: return tRefs - def getTagSource(self, tagKey: str) -> tuple[str, str]: + def getTagSource(self, tagKey: str) -> tuple[str | None, str]: """Return the source location of a given tag.""" tHandle = self._tagsIndex.tagHandle(tagKey) sTitle = self._tagsIndex.tagHeading(tagKey) @@ -625,6 +638,14 @@ class NWIndex: """Return all tags based on itemClass.""" return self._tagsIndex.filterTagNames(itemClass.name) + def getTagsData(self) -> Iterator[tuple[str, str, str, IndexItem | None, IndexHeading | None]]: + """Return all known tags.""" + for tag, data in self._tagsIndex.items(): + iItem = self._itemIndex[data.get("handle")] + hItem = None if iItem is None else iItem[data.get("heading")] + yield tag, data.get("name", ""), data.get("class", ""), iItem, hItem + return + # END Class NWIndex @@ -643,7 +664,7 @@ class TagsIndex: __slots__ = ("_tags") def __init__(self) -> None: - self._tags: dict[str, dict] = {} + self._tags: dict[str, dict[str, str]] = {} return def __contains__(self, tagKey: str) -> bool: @@ -665,6 +686,10 @@ class TagsIndex: self._tags = {} return + def items(self) -> ItemsView: + """Return a dictionary view of all tags.""" + return self._tags.items() + def add(self, tagKey: str, tHandle: str, sTitle: str, itemClass: nwItemClass) -> None: """Add a key to the index and set all values.""" self._tags[tagKey.lower()] = { @@ -676,7 +701,7 @@ class TagsIndex: """Get the display name of a given tag.""" return self._tags.get(tagKey.lower(), {}).get("name", "") - def tagHandle(self, tagKey: str) -> str: + def tagHandle(self, tagKey: str) -> str | None: """Get the handle of a given tag.""" return self._tags.get(tagKey.lower(), {}).get("handle", None) @@ -937,6 +962,11 @@ class IndexItem: # 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.""" diff --git a/novelwriter/enum.py b/novelwriter/enum.py index 3d652266..c1d2aef4 100644 --- a/novelwriter/enum.py +++ b/novelwriter/enum.py @@ -64,7 +64,7 @@ class nwItemLayout(Enum): class nwTrinary(Enum): NEGATIVE = -1 - UNKNOWN = 0 + NEUTRAL = 0 POSITIVE = 1 # END Enum nwTrinary diff --git a/novelwriter/gui/doceditor.py b/novelwriter/gui/doceditor.py index d4743c9b..00e3a093 100644 --- a/novelwriter/gui/doceditor.py +++ b/novelwriter/gui/doceditor.py @@ -1783,13 +1783,13 @@ class GuiDocEditor(QPlainTextEdit): block = cursor.block() text = block.text() if len(text) == 0: - return nwTrinary.UNKNOWN + return nwTrinary.NEUTRAL if text.startswith("@") and isinstance(self._nwItem, NWItem): isGood, tBits, tPos = SHARED.project.index.scanThis(text) if not isGood: - return nwTrinary.UNKNOWN + return nwTrinary.NEUTRAL tag = "" exist = False @@ -1806,7 +1806,7 @@ class GuiDocEditor(QPlainTextEdit): if not tag or tag.startswith("@"): # The keyword cannot be looked up, so we ignore that - return nwTrinary.UNKNOWN + return nwTrinary.NEUTRAL if follow and exist: logger.debug("Attempting to follow tag '%s'", tag) @@ -1826,7 +1826,7 @@ class GuiDocEditor(QPlainTextEdit): return nwTrinary.POSITIVE if exist else nwTrinary.NEGATIVE - return nwTrinary.UNKNOWN + return nwTrinary.NEUTRAL def _openContextFromCursor(self) -> None: """Open the spell check context menu at the cursor.""" diff --git a/novelwriter/shared.py b/novelwriter/shared.py index 8ee3099a..deb49ce1 100644 --- a/novelwriter/shared.py +++ b/novelwriter/shared.py @@ -52,6 +52,7 @@ class SharedData(QObject): projectStatusChanged = pyqtSignal(bool) projectStatusMessage = pyqtSignal(str) spellLanguageChanged = pyqtSignal(str, str) + indexChangedTags = pyqtSignal(list[str], list[str]) def __init__(self) -> None: super().__init__() @@ -171,7 +172,7 @@ class SharedData(QObject): return def updateSpellCheckLanguage(self, reload: bool = False) -> None: - """Update the active spell check langauge from settings.""" + """Update the active spell check language from settings.""" from novelwriter import CONFIG language = self.project.data.spellLang or CONFIG.spellLanguage if language != self.spelling.spellLanguage or reload: @@ -210,6 +211,15 @@ class SharedData(QObject): QThreadPool.globalInstance().start(runnable, priority=priority) return + ## + # Call-Back Functions + ## + + def indexUpdatedTags(self, added: list[str], deleted: list[str]) -> None: + """Emit the index changed tags signal.""" + self.indexChangedTags.emit(added, deleted) + return + ## # Alert Boxes ## diff --git a/tests/test_gui/test_gui_doceditor.py b/tests/test_gui/test_gui_doceditor.py index 008ad053..22a67be4 100644 --- a/tests/test_gui/test_gui_doceditor.py +++ b/tests/test_gui/test_gui_doceditor.py @@ -1194,15 +1194,15 @@ def testGuiEditor_Tags(qtbot, nwGUI, projPath, ipsumText, mockRnd): # Empty Block nwGUI.docEditor.setCursorLine(2) - assert nwGUI.docEditor._processTag() is nwTrinary.UNKNOWN + assert nwGUI.docEditor._processTag() is nwTrinary.NEUTRAL # Not On Tag nwGUI.docEditor.setCursorLine(1) - assert nwGUI.docEditor._processTag() is nwTrinary.UNKNOWN + assert nwGUI.docEditor._processTag() is nwTrinary.NEUTRAL # On Tag Keyword nwGUI.docEditor.setCursorPosition(15) - assert nwGUI.docEditor._processTag() is nwTrinary.UNKNOWN + assert nwGUI.docEditor._processTag() is nwTrinary.NEUTRAL # On Known Tag, No Follow nwGUI.docEditor.setCursorPosition(22) @@ -1230,7 +1230,7 @@ def testGuiEditor_Tags(qtbot, nwGUI, projPath, ipsumText, mockRnd): assert "0000000000012" not in SHARED.project.tree nwGUI.docEditor.setCursorPosition(47) - assert nwGUI.docEditor._processTag() is nwTrinary.UNKNOWN + assert nwGUI.docEditor._processTag() is nwTrinary.NEUTRAL # qtbot.stop()