Add search caching

This commit is contained in:
Veronica Berglyd Olsen
2024-03-25 16:19:51 +01:00
parent c6944876f1
commit 16954c3bba
5 changed files with 125 additions and 32 deletions
+1
View File
@@ -53,6 +53,7 @@ class nwConst:
# Gui Settings # Gui Settings
STATUS_MSG_TIMEOUT = 15000 # milliseconds STATUS_MSG_TIMEOUT = 15000 # milliseconds
MAX_SEARCH_RESULT = 1000
# Dialogs # Dialogs
DLG_FINISHED = 2 DLG_FINISHED = 2
+65 -15
View File
@@ -306,25 +306,73 @@ class DocDuplicator:
class DocSearch: class DocSearch:
def __init__(self, project: NWProject, regEx: bool, doCase: bool, wholeWords: bool) -> None: def __init__(self) -> None:
self._project = project # RegEx Object
self._escape = not regEx self._regEx = QRegularExpression()
self._words = wholeWords self.setCaseSensitive(False)
self._rxOpts = QRegularExpression.PatternOption.UseUnicodePropertiesOption self._words = False
if not doCase: self._escape = True
self._rxOpts |= QRegularExpression.PatternOption.CaseInsensitiveOption
# Project Cache
self._uuid = ""
self._cache: dict[str, str] = {}
return return
def iterSearch(self, search: str) -> Iterable[tuple[NWItem, list[tuple[int, int, str]]]]: ##
"""Iteratively search through documents in the project.""" # Methods
##
def setCaseSensitive(self, state: bool) -> None:
"""Set the case sensitive search flag."""
opts = QRegularExpression.PatternOption.UseUnicodePropertiesOption
if not state:
opts |= QRegularExpression.PatternOption.CaseInsensitiveOption
self._regEx.setPatternOptions(opts)
return
def setWholeWords(self, state: bool) -> None:
"""Set the whole words search flag."""
self._words = state
return
def setUserRegEx(self, state: bool) -> None:
"""Set the escape flag to the opposite state."""
self._escape = not state
return
def clearTextCache(self, tHandle: str | None) -> None:
"""Clear text cache for a given item, or all items if None."""
if tHandle is None:
self._cache = {}
logger.debug("Search cache cleared")
elif tHandle in self._cache:
self._cache.pop(tHandle, None)
logger.debug("Search cache cleared for '%s'", tHandle)
return
def iterSearch(
self, project: NWProject, search: str
) -> Iterable[tuple[NWItem, list[tuple[int, int, str]]]]:
"""Iteratively search through documents in a project."""
if project.data.uuid != self._uuid:
self.clearTextCache(None)
self._uuid = project.data.uuid
self._regEx.setPattern(self._buildPattern(search))
logger.debug("Searching with pattern '%s'", self._regEx.pattern())
num = len(search) num = len(search)
storage = self._project.storage storage = project.storage
regEx = QRegularExpression(self._buildPattern(search), self._rxOpts) for item in project.tree:
logger.debug("Searching with pattern '%s'", regEx.pattern())
for item in self._project.tree:
if item.isFileType(): if item.isFileType():
text = storage.getDocument(item.itemHandle).readDocument() or "" tHandle = item.itemHandle
rxItt = regEx.globalMatch(text) text = self._cache.get(tHandle)
if text is None:
text = storage.getDocument(tHandle).readDocument() or ""
self._cache[tHandle] = text
rxItt = self._regEx.globalMatch(text)
results = [] results = []
while rxItt.hasNext(): while rxItt.hasNext():
rxMatch = rxItt.next() rxMatch = rxItt.next()
@@ -332,7 +380,9 @@ class DocSearch:
num = rxMatch.capturedLength() num = rxMatch.capturedLength()
context = text[pos:pos+100].partition("\n")[0] context = text[pos:pos+100].partition("\n")[0]
results.append((pos, num, context)) results.append((pos, num, context))
yield item, results yield item, results
return return
## ##
+16 -9
View File
@@ -54,7 +54,7 @@ from PyQt5.QtWidgets import (
from novelwriter import CONFIG, SHARED from novelwriter import CONFIG, SHARED
from novelwriter.common import minmax, transferCase from novelwriter.common import minmax, transferCase
from novelwriter.constants import nwKeyWords, nwShortcode, nwUnicode from novelwriter.constants import nwConst, nwKeyWords, nwShortcode, nwUnicode
from novelwriter.core.document import NWDocument from novelwriter.core.document import NWDocument
from novelwriter.enum import nwDocAction, nwDocInsert, nwDocMode, nwItemClass, nwTrinary from novelwriter.enum import nwDocAction, nwDocInsert, nwDocMode, nwItemClass, nwTrinary
from novelwriter.extensions.eventfilters import WheelEventFilter from novelwriter.extensions.eventfilters import WheelEventFilter
@@ -91,6 +91,7 @@ class GuiDocEditor(QPlainTextEdit):
# Custom Signals # Custom Signals
statusMessage = pyqtSignal(str) statusMessage = pyqtSignal(str)
docTextSaved = pyqtSignal(str)
docCountsChanged = pyqtSignal(str, int, int, int) docCountsChanged = pyqtSignal(str, int, int, int)
editedStatusChanged = pyqtSignal(bool) editedStatusChanged = pyqtSignal(bool)
loadDocumentTagRequest = pyqtSignal(str, Enum) loadDocumentTagRequest = pyqtSignal(str, Enum)
@@ -488,6 +489,7 @@ class GuiDocEditor(QPlainTextEdit):
return False return False
self.setDocumentChanged(False) self.setDocumentChanged(False)
self.docTextSaved.emit(tHandle)
oldHeader = self._nwItem.mainHeading oldHeader = self._nwItem.mainHeading
oldCount = SHARED.project.index.getHandleHeaderCount(tHandle) oldCount = SHARED.project.index.getHandleHeaderCount(tHandle)
@@ -603,13 +605,15 @@ class GuiDocEditor(QPlainTextEdit):
# Setters # Setters
## ##
def setDocumentChanged(self, state: bool) -> bool: def setDocumentChanged(self, state: bool) -> None:
"""Keep track of the document changed variable, and emit the """Keep track of the document changed variable, and emit the
document change signal. document change signal.
""" """
self._docChanged = state if self._docChanged != state:
self.editedStatusChanged.emit(self._docChanged) logger.debug("Document changed status is '%s'", state)
return self._docChanged self._docChanged = state
self.editedStatusChanged.emit(self._docChanged)
return
def setCursorPosition(self, position: int) -> None: def setCursorPosition(self, position: int) -> None:
"""Move the cursor to a given position in the document.""" """Move the cursor to a given position in the document."""
@@ -1372,9 +1376,10 @@ class GuiDocEditor(QPlainTextEdit):
cursor.setPosition(0) cursor.setPosition(0)
self.setTextCursor(cursor) self.setTextCursor(cursor)
# Search up to a maximum of 1000, and make sure certain special # Search up to a maximum of MAX_SEARCH_RESULT, and make sure
# searches like a regex search for .* don't loop infinitely # certain special searches like a regex search for .* don't loop
while self.find(searchFor, findOpt) and len(resE) <= 1000: # infinitely
while self.find(searchFor, findOpt) and len(resE) <= nwConst.MAX_SEARCH_RESULT:
cursor = self.textCursor() cursor = self.textCursor()
if cursor.hasSelection(): if cursor.hasSelection():
resS.append(cursor.selectionStart()) resS.append(cursor.selectionStart())
@@ -2613,8 +2618,10 @@ class GuiDocEditSearch(QFrame):
def setResultCount(self, currRes: int | None, resCount: int | None) -> None: def setResultCount(self, currRes: int | None, resCount: int | None) -> None:
"""Set the count values for the current search.""" """Set the count values for the current search."""
lim = nwConst.MAX_SEARCH_RESULT
numCount = f"{lim:n}+" if (resCount or 0) > lim else f"{resCount:n}"
sCurrRes = "?" if currRes is None else str(currRes) sCurrRes = "?" if currRes is None else str(currRes)
sResCount = "?" if resCount is None else "1000+" if resCount > 1000 else str(resCount) sResCount = "?" if resCount is None else numCount
minWidth = SHARED.theme.getTextWidth(f"{sResCount}//{sResCount}", self.boxFont) minWidth = SHARED.theme.getTextWidth(f"{sResCount}//{sResCount}", self.boxFont)
self.resultLabel.setText(f"{sCurrRes}/{sResCount}") self.resultLabel.setText(f"{sCurrRes}/{sResCount}")
self.resultLabel.setMinimumWidth(minWidth) self.resultLabel.setMinimumWidth(minWidth)
+41 -8
View File
@@ -29,11 +29,12 @@ from PyQt5.QtCore import QSize, Qt, pyqtSignal, pyqtSlot
from PyQt5.QtGui import QKeyEvent, QPalette from PyQt5.QtGui import QKeyEvent, QPalette
from PyQt5.QtWidgets import ( from PyQt5.QtWidgets import (
QHBoxLayout, QLabel, QLineEdit, QToolBar, QTreeWidget, QTreeWidgetItem, QHBoxLayout, QLabel, QLineEdit, QToolBar, QTreeWidget, QTreeWidgetItem,
QVBoxLayout, QWidget QVBoxLayout, QWidget, qApp
) )
from novelwriter import CONFIG, SHARED from novelwriter import CONFIG, SHARED
from novelwriter.common import checkInt from novelwriter.common import checkInt
from novelwriter.constants import nwConst
from novelwriter.core.coretools import DocSearch from novelwriter.core.coretools import DocSearch
from novelwriter.core.item import NWItem from novelwriter.core.item import NWItem
@@ -55,6 +56,8 @@ class GuiProjectSearch(QWidget):
iPx = SHARED.theme.baseIconSize iPx = SHARED.theme.baseIconSize
mPx = CONFIG.pxInt(2) mPx = CONFIG.pxInt(2)
self._search = DocSearch()
# Header # Header
self.viewLabel = QLabel(self.tr("Project Search")) self.viewLabel = QLabel(self.tr("Project Search"))
self.viewLabel.setFont(SHARED.theme.guiFontB) self.viewLabel.setFont(SHARED.theme.guiFontB)
@@ -150,6 +153,12 @@ class GuiProjectSearch(QWidget):
self.searchText.selectAll() self.searchText.selectAll()
return return
def closeProjectTasks(self) -> None:
"""Run close project tasks."""
self.searchText.clear()
self.searchResult.clear()
return
## ##
# Events # Events
## ##
@@ -177,6 +186,16 @@ class GuiProjectSearch(QWidget):
super().keyPressEvent(event) super().keyPressEvent(event)
return return
##
# Public Slots
##
@pyqtSlot(str)
def clearSearchCache(self, tHandle: str) -> None:
"""Process document content change."""
self._search.clearTextCache(tHandle)
return
## ##
# Private Slots # Private Slots
## ##
@@ -186,11 +205,10 @@ class GuiProjectSearch(QWidget):
"""Perform a search.""" """Perform a search."""
self.searchResult.clear() self.searchResult.clear()
if text := self.searchText.text(): if text := self.searchText.text():
search = DocSearch( self._search.setUserRegEx(self.toggleRegEx.isChecked())
SHARED.project, self.toggleRegEx.isChecked(), self._search.setCaseSensitive(self.toggleCase.isChecked())
self.toggleCase.isChecked(), self.toggleWord.isChecked() self._search.setWholeWords(self.toggleWord.isChecked())
) for item, results in self._search.iterSearch(SHARED.project, text):
for item, results in search.iterSearch(text):
self._appendResultSet(item, results) self._appendResultSet(item, results)
return return
@@ -234,6 +252,10 @@ class GuiProjectSearch(QWidget):
# Internal Functions # Internal Functions
## ##
def _initSearch(self) -> None:
"""Initialise the search."""
return
def _appendResultSet(self, nwItem: NWItem, results: list[tuple[int, int, str]]) -> None: def _appendResultSet(self, nwItem: NWItem, results: list[tuple[int, int, str]]) -> None:
"""Populate the result tree.""" """Populate the result tree."""
if results: if results:
@@ -242,20 +264,31 @@ class GuiProjectSearch(QWidget):
nwItem.itemType, nwItem.itemClass, nwItem.itemType, nwItem.itemClass,
nwItem.itemLayout, nwItem.mainHeading nwItem.itemLayout, nwItem.mainHeading
) )
lim = nwConst.MAX_SEARCH_RESULT
count = len(results)
numResult = f"{count:n}"
if count > lim:
results = results[:lim]
numResult = f"{lim:n}+"
tItem = QTreeWidgetItem() tItem = QTreeWidgetItem()
tItem.setText(0, f"{nwItem.itemName} ({len(results)})") tItem.setText(0, f"{nwItem.itemName} ({numResult})")
tItem.setIcon(0, docIcon) tItem.setIcon(0, docIcon)
tItem.setData(0, self.D_HANDLE, tHandle) tItem.setData(0, self.D_HANDLE, tHandle)
self.searchResult.addTopLevelItem(tItem)
rItems = [] rItems = []
for start, length, context in results: for start, length, context in results:
rItem = QTreeWidgetItem() rItem = QTreeWidgetItem()
rItem.setText(0, context) rItem.setText(0, context)
rItem.setData(0, self.D_RESULT, (tHandle, start, length)) rItem.setData(0, self.D_RESULT, (tHandle, start, length))
rItems.append(rItem) rItems.append(rItem)
tItem.addChildren(rItems) tItem.addChildren(rItems)
tItem.setExpanded(True) tItem.setExpanded(True)
self.searchResult.addTopLevelItem(tItem)
qApp.processEvents()
return return
# END Class GuiProjectSearch # END Class GuiProjectSearch
+2
View File
@@ -260,6 +260,7 @@ class GuiMain(QMainWindow):
self.docEditor.requestProjectItemSelected.connect(self.projView.setSelectedHandle) self.docEditor.requestProjectItemSelected.connect(self.projView.setSelectedHandle)
self.docEditor.requestProjectItemRenamed.connect(self.projView.renameTreeItem) self.docEditor.requestProjectItemRenamed.connect(self.projView.renameTreeItem)
self.docEditor.requestNewNoteCreation.connect(self.projView.createNewNote) self.docEditor.requestNewNoteCreation.connect(self.projView.createNewNote)
self.docEditor.docTextSaved.connect(self.projSearch.clearSearchCache)
self.docViewer.documentLoaded.connect(self.docViewerPanel.updateHandle) self.docViewer.documentLoaded.connect(self.docViewerPanel.updateHandle)
self.docViewer.loadDocumentTagRequest.connect(self._followTag) self.docViewer.loadDocumentTagRequest.connect(self._followTag)
@@ -394,6 +395,7 @@ class GuiMain(QMainWindow):
self.outlineView.closeProjectTasks() self.outlineView.closeProjectTasks()
self.novelView.closeProjectTasks() self.novelView.closeProjectTasks()
self.projView.closeProjectTasks() self.projView.closeProjectTasks()
self.projSearch.closeProjectTasks()
self.itemDetails.clearDetails() self.itemDetails.clearDetails()
self.mainStatus.clearStatus() self.mainStatus.clearStatus()