Add fast document read (#1777)

This commit is contained in:
Veronica Berglyd Olsen
2024-03-26 12:19:50 +01:00
committed by GitHub
11 changed files with 81 additions and 68 deletions
+5 -26
View File
@@ -32,7 +32,6 @@ import shutil
from collections.abc import Iterable from collections.abc import Iterable
from functools import partial from functools import partial
from pathlib import Path from pathlib import Path
from time import time
from zipfile import ZipFile, is_zipfile from zipfile import ZipFile, is_zipfile
from PyQt5.QtCore import QCoreApplication, QRegularExpression from PyQt5.QtCore import QCoreApplication, QRegularExpression
@@ -102,9 +101,7 @@ class DocMerger:
if srcItem is None: if srcItem is None:
return False return False
inDoc = self._project.storage.getDocument(srcHandle) docText = self._project.storage.getDocumentText(srcHandle).rstrip("\n")
docText = (inDoc.readDocument() or "").rstrip("\n")
if addComment: if addComment:
docInfo = srcItem.describeMe() docInfo = srcItem.describeMe()
docSt, _ = srcItem.getImportStatus(incIcon=False) docSt, _ = srcItem.getImportStatus(incIcon=False)
@@ -123,9 +120,8 @@ class DocMerger:
return False return False
outDoc = self._project.storage.getDocument(self._targetDoc) outDoc = self._project.storage.getDocument(self._targetDoc)
docText = (outDoc.readDocument() or "").rstrip("\n") if text := (outDoc.readDocument() or "").rstrip("\n"):
if docText: self._targetText.insert(0, text)
self._targetText.insert(0, docText)
status = outDoc.writeDocument("\n\n".join(self._targetText) + "\n\n") status = outDoc.writeDocument("\n\n".join(self._targetText) + "\n\n")
if not status: if not status:
@@ -293,11 +289,10 @@ class DocDuplicator:
newItem.setParent(hMap[newItem.itemParent]) newItem.setParent(hMap[newItem.itemParent])
self._project.tree.updateItemData(newItem.itemHandle) self._project.tree.updateItemData(newItem.itemHandle)
if newItem.isFileType(): if newItem.isFileType():
oldDoc = self._project.storage.getDocument(tHandle)
newDoc = self._project.storage.getDocument(newItem.itemHandle) newDoc = self._project.storage.getDocument(newItem.itemHandle)
if newDoc.fileExists(): if newDoc.fileExists():
return return
newDoc.writeDocument(oldDoc.readDocument() or "") newDoc.writeDocument(self._project.storage.getDocumentText(tHandle))
yield newItem.itemHandle, nHandle yield newItem.itemHandle, nHandle
nHandle = None nHandle = None
return return
@@ -308,17 +303,10 @@ class DocDuplicator:
class DocSearch: class DocSearch:
def __init__(self) -> None: def __init__(self) -> None:
# RegEx Object
self._regEx = QRegularExpression() self._regEx = QRegularExpression()
self.setCaseSensitive(False) self.setCaseSensitive(False)
self._words = False self._words = False
self._escape = True self._escape = True
# Project Cache
self._uuid = ""
self._time = 0.0
self._cache: dict[str, str] = {}
return return
## ##
@@ -347,11 +335,6 @@ class DocSearch:
self, project: NWProject, search: str self, project: NWProject, search: str
) -> Iterable[tuple[NWItem, list[tuple[int, int, str]], bool]]: ) -> Iterable[tuple[NWItem, list[tuple[int, int, str]], bool]]:
"""Iteratively search through documents in a project.""" """Iteratively search through documents in a project."""
if project.data.uuid != self._uuid or time() - self._time > 20.0:
self._cache = {}
self._uuid = project.data.uuid
self._time = time()
self._regEx.setPattern(self._buildPattern(search)) self._regEx.setPattern(self._buildPattern(search))
logger.debug("Searching with pattern '%s'", self._regEx.pattern()) logger.debug("Searching with pattern '%s'", self._regEx.pattern())
@@ -359,11 +342,7 @@ class DocSearch:
storage = project.storage storage = project.storage
for item in project.tree: for item in project.tree:
if item.isFileType(): if item.isFileType():
tHandle = item.itemHandle text = storage.getDocumentText(item.itemHandle)
if (text := self._cache.get(tHandle)) is None:
text = storage.getDocument(tHandle).readDocument() or ""
self._cache[tHandle] = text
rxItt = self._regEx.globalMatch(text) rxItt = self._regEx.globalMatch(text)
count = 0 count = 0
capped = False capped = False
+24 -3
View File
@@ -31,7 +31,7 @@ from typing import TYPE_CHECKING
from pathlib import Path from pathlib import Path
from novelwriter.enum import nwItemLayout, nwItemClass from novelwriter.enum import nwItemLayout, nwItemClass
from novelwriter.error import formatException from novelwriter.error import formatException, logException
from novelwriter.common import formatTimeStamp, isHandle from novelwriter.common import formatTimeStamp, isHandle
from novelwriter.core.item import NWItem from novelwriter.core.item import NWItem
@@ -106,7 +106,28 @@ class NWDocument:
return self._item return self._item
## ##
# Class Methods # Static Methods
##
@staticmethod
def quickReadText(content: Path, tHandle: str) -> str:
"""Return the text of a document in a fast and efficient way."""
if (path := content / f"{tHandle}.nwd").is_file():
try:
with open(path, mode="r", encoding="utf-8") as inFile:
line = ""
for _ in range(10):
if not (line := inFile.readline()).startswith(r"%%~"):
break
return line + inFile.read()
except Exception:
logger.error("Cannot read document with handle '%s'", tHandle)
logException()
return ""
return ""
##
# Methods
## ##
def fileExists(self) -> bool: def fileExists(self) -> bool:
@@ -155,7 +176,7 @@ class NWDocument:
try: try:
with open(docPath, mode="r", encoding="utf-8") as inFile: with open(docPath, mode="r", encoding="utf-8") as inFile:
# Check the first <= 10 lines for metadata # Check the first <= 10 lines for metadata
for i in range(10): for _ in range(10):
line = inFile.readline() line = inFile.readline()
if line.startswith(r"%%~"): if line.startswith(r"%%~"):
self._parseMeta(line) self._parseMeta(line)
+5 -8
View File
@@ -122,9 +122,8 @@ class NWIndex:
self.clearIndex() self.clearIndex()
for nwItem in self._project.tree: for nwItem in self._project.tree:
if nwItem.isFileType(): if nwItem.isFileType():
tHandle = nwItem.itemHandle text = self._project.storage.getDocumentText(nwItem.itemHandle)
doc = self._project.storage.getDocument(tHandle) self.scanText(nwItem.itemHandle, text, blockSignal=True)
self.scanText(tHandle, doc.readDocument() or "", blockSignal=True)
self._indexBroken = False self._indexBroken = False
SHARED.indexSignalProxy({"event": "buildIndex"}) SHARED.indexSignalProxy({"event": "buildIndex"})
return return
@@ -142,17 +141,15 @@ class NWIndex:
}) })
return return
def reIndexHandle(self, tHandle: str | None) -> bool: def reIndexHandle(self, tHandle: str | None) -> None:
"""Put a file back into the index. This is used when files are """Put a file back into the index. This is used when files are
moved from the archive or trash folders back into the active moved from the archive or trash folders back into the active
project. project.
""" """
if tHandle and self._project.tree.checkType(tHandle, nwItemType.FILE): if tHandle and self._project.tree.checkType(tHandle, nwItemType.FILE):
logger.debug("Re-indexing item '%s'", tHandle) logger.debug("Re-indexing item '%s'", tHandle)
doc = self._project.storage.getDocument(tHandle) self.scanText(tHandle, self._project.storage.getDocumentText(tHandle))
self.scanText(tHandle, doc.readDocument() or "") return
return True
return False
def indexChangedSince(self, checkTime: int | float) -> bool: def indexChangedSince(self, checkTime: int | float) -> bool:
"""Check if the index has changed since a given time.""" """Check if the index has changed since a given time."""
+8 -13
View File
@@ -175,12 +175,10 @@ class NWProject:
"""Write content to a new document after it is created. This """Write content to a new document after it is created. This
will not run if the file exists and is not empty. will not run if the file exists and is not empty.
""" """
tItem = self._tree[tHandle] if not ((tItem := self._tree[tHandle]) and tItem.isFileType()):
if not (tItem and tItem.isFileType()):
return False return False
newDoc = self._storage.getDocument(tHandle) if self._storage.getDocumentText(tHandle).strip():
if (newDoc.readDocument() or "").strip():
return False return False
indent = "#"*minmax(hLevel, 1, 4) indent = "#"*minmax(hLevel, 1, 4)
@@ -191,7 +189,7 @@ class NWProject:
else: else:
tItem.setLayout(nwItemLayout.NOTE) tItem.setLayout(nwItemLayout.NOTE)
newDoc.writeDocument(text) self._storage.getDocument(tHandle).writeDocument(text)
self._index.scanText(tHandle, text) self._index.scanText(tHandle, text)
return True return True
@@ -200,21 +198,18 @@ class NWProject:
"""Copy content to a new document after it is created. This """Copy content to a new document after it is created. This
will not run if the file exists and is not empty. will not run if the file exists and is not empty.
""" """
tItem = self._tree[tHandle] if not ((tItem := self._tree[tHandle]) and tItem.isFileType()):
if not (tItem and tItem.isFileType()):
return False return False
sItem = self._tree[sHandle] if not ((sItem := self._tree[sHandle]) and sItem.isFileType()):
if not (sItem and sItem.isFileType()):
return False return False
newDoc = self._storage.getDocument(tHandle) if self._storage.getDocumentText(tHandle).strip():
if (newDoc.readDocument() or "").strip():
return False return False
logger.debug("Populating '%s' with text from '%s'", tHandle, sHandle) logger.debug("Populating '%s' with text from '%s'", tHandle, sHandle)
text = self._storage.getDocument(sHandle).readDocument() or "" text = self._storage.getDocumentText(sHandle)
newDoc.writeDocument(text) self._storage.getDocument(tHandle).writeDocument(text)
sItem.setLayout(tItem.itemLayout) sItem.setLayout(tItem.itemLayout)
self._index.scanText(tHandle, text) self._index.scanText(tHandle, text)
+8 -2
View File
@@ -27,18 +27,18 @@ import json
import logging import logging
from enum import Enum from enum import Enum
from pathlib import Path
from time import time from time import time
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from pathlib import Path
from zipfile import ZIP_DEFLATED, ZIP_STORED, ZipFile from zipfile import ZIP_DEFLATED, ZIP_STORED, ZipFile
from novelwriter import CONFIG from novelwriter import CONFIG
from novelwriter.error import logException
from novelwriter.common import isHandle, minmax from novelwriter.common import isHandle, minmax
from novelwriter.constants import nwFiles from novelwriter.constants import nwFiles
from novelwriter.core.document import NWDocument from novelwriter.core.document import NWDocument
from novelwriter.core.projectxml import ProjectXMLReader, ProjectXMLWriter from novelwriter.core.projectxml import ProjectXMLReader, ProjectXMLWriter
from novelwriter.core.spellcheck import UserDictionary from novelwriter.core.spellcheck import UserDictionary
from novelwriter.error import logException
if TYPE_CHECKING: # pragma: no cover if TYPE_CHECKING: # pragma: no cover
from novelwriter.core.project import NWProject from novelwriter.core.project import NWProject
@@ -287,6 +287,12 @@ class NWStorage:
return self._runtimePath / "meta" / fileName return self._runtimePath / "meta" / fileName
return None return None
def getDocumentText(self, tHandle: str) -> str:
"""Return the text of a document in a fast and efficient way."""
if isinstance(self._runtimePath, Path):
return NWDocument.quickReadText(self._runtimePath / "content", tHandle)
return ""
def scanContent(self) -> list[str]: def scanContent(self) -> list[str]:
"""Scan the content folder and return the handle of all files """Scan the content folder and return the handle of all files
found in it. Files that do not match the pattern are ignored. found in it. Files that do not match the pattern are ignored.
+1 -3
View File
@@ -429,9 +429,7 @@ class Tokenizer(ABC):
self._text = "" self._text = ""
self._handle = None self._handle = None
if nwItem := self._project.tree[tHandle]: if nwItem := self._project.tree[tHandle]:
if text is None: self._text = text or self._project.storage.getDocumentText(tHandle)
text = self._project.storage.getDocument(tHandle).readDocument() or ""
self._text = text
self._handle = tHandle self._handle = tHandle
self._isNovel = nwItem.itemLayout == nwItemLayout.DOCUMENT self._isNovel = nwItem.itemLayout == nwItemLayout.DOCUMENT
return return
+1 -2
View File
@@ -214,8 +214,7 @@ class GuiDocSplit(QDialog):
spLevel = self.splitLevel.currentData() spLevel = self.splitLevel.currentData()
if not self._text: if not self._text:
inDoc = SHARED.project.storage.getDocument(sHandle) self._text = SHARED.project.storage.getDocumentText(sHandle).splitlines()
self._text = (inDoc.readDocument() or "").splitlines()
for lineNo, aLine in enumerate(self._text): for lineNo, aLine in enumerate(self._text):
-3
View File
@@ -423,9 +423,6 @@ def testCoreTools_DocSearch(monkeypatch, mockGUI, fncPath, mockRnd, ipsumText):
assert result[1] == (C.hChapterDoc, [], False) assert result[1] == (C.hChapterDoc, [], False)
assert result[2] == (C.hSceneDoc, [(8, 5, "Scene")], False) assert result[2] == (C.hSceneDoc, [(8, 5, "Scene")], False)
# Cache
assert list(search._cache.keys()) == [C.hTitlePage, C.hChapterDoc, C.hSceneDoc]
# Patterns # Patterns
# ======== # ========
+19 -1
View File
@@ -47,6 +47,9 @@ def testCoreDocument_LoadSave(monkeypatch, mockGUI, fncPath, mockRnd):
assert bool(doc) is False assert bool(doc) is False
assert doc.readDocument() is None assert doc.readDocument() is None
assert doc.fileExists() is False assert doc.fileExists() is False
assert doc.hashError is False
assert doc.createdDate == "Unknown"
assert doc.updatedDate == "Unknown"
# Non-existent handle # Non-existent handle
doc = NWDocument(project, C.hInvalid) doc = NWDocument(project, C.hInvalid)
@@ -76,6 +79,7 @@ def testCoreDocument_LoadSave(monkeypatch, mockGUI, fncPath, mockRnd):
# Try to open a new (non-existent) file # Try to open a new (non-existent) file
xHandle = project.newFile("New File", C.hNovelRoot) xHandle = project.newFile("New File", C.hNovelRoot)
assert xHandle is not None
doc = NWDocument(project, xHandle) doc = NWDocument(project, xHandle)
assert bool(doc) is True assert bool(doc) is True
assert repr(doc) == f"<NWDocument handle={xHandle}>" assert repr(doc) == f"<NWDocument handle={xHandle}>"
@@ -93,7 +97,6 @@ def testCoreDocument_LoadSave(monkeypatch, mockGUI, fncPath, mockRnd):
# Set handle and save # Set handle and save
text = "### Test File\n\nText ...\n\n" text = "### Test File\n\nText ...\n\n"
doc = NWDocument(project, xHandle) doc = NWDocument(project, xHandle)
assert doc.readDocument(xHandle) == "" # type: ignore
assert doc.writeDocument(text) is True assert doc.writeDocument(text) is True
# Save again to ensure temp file and previous file is handled # Save again to ensure temp file and previous file is handled
@@ -145,6 +148,21 @@ def testCoreDocument_LoadSave(monkeypatch, mockGUI, fncPath, mockRnd):
doc._handle = None doc._handle = None
assert doc.writeDocument(text) is False assert doc.writeDocument(text) is False
# Quick Read
# ==========
contPath = fncPath / "content"
assert NWDocument.quickReadText(contPath, xHandle) == (
"### Test File\n\n"
"Text ...\n\n"
)
# Check read text fallback
assert NWDocument.quickReadText(contPath, "0000000000000") == ""
with monkeypatch.context() as mp:
mp.setattr("builtins.open", causeOSError)
assert NWDocument.quickReadText(contPath, xHandle) == ""
# Delete Document # Delete Document
# =============== # ===============
+2 -3
View File
@@ -60,9 +60,8 @@ def testCoreIndex_LoadSave(qtbot, monkeypatch, prjLipsum, mockGUI, tstPaths):
"60bdf227455cc": False, # World ROOT "60bdf227455cc": False, # World ROOT
} }
for tItem in project.tree: for tItem in project.tree:
assert index.reIndexHandle(tItem.itemHandle) is notIndexable.get(tItem.itemHandle, True) index.reIndexHandle(tItem.itemHandle)
assert (tItem.itemHandle in index._itemIndex) is notIndexable.get(tItem.itemHandle, True)
assert index.reIndexHandle(None) is False
# No folder for saving # No folder for saving
with monkeypatch.context() as mp: with monkeypatch.context() as mp:
+8 -4
View File
@@ -26,15 +26,15 @@ import pytest
from pathlib import Path from pathlib import Path
from zipfile import ZipFile from zipfile import ZipFile
from tools import C, buildTestProject
from mocked import causeOSError from mocked import causeOSError
from tools import C, buildTestProject
from novelwriter import CONFIG from novelwriter import CONFIG
from novelwriter.constants import nwFiles from novelwriter.constants import nwFiles
from novelwriter.core.project import NWProject
from novelwriter.core.storage import NWStorage, NWStorageOpen, NWStorageCreate, _LegacyStorage
from novelwriter.core.document import NWDocument from novelwriter.core.document import NWDocument
from novelwriter.core.project import NWProject
from novelwriter.core.projectxml import ProjectXMLReader, ProjectXMLWriter from novelwriter.core.projectxml import ProjectXMLReader, ProjectXMLWriter
from novelwriter.core.storage import NWStorage, NWStorageOpen, NWStorageCreate, _LegacyStorage
class MockProject: class MockProject:
@@ -64,6 +64,7 @@ def testCoreStorage_CreateNewProject(mockGUI, fncPath):
assert bool(storage.getDocument(C.hSceneDoc)) is False assert bool(storage.getDocument(C.hSceneDoc)) is False
assert storage.getMetaFile("file") is None assert storage.getMetaFile("file") is None
assert storage.scanContent() == [] assert storage.scanContent() == []
assert storage.getDocumentText(C.hSceneDoc) == ""
# Cannot prepare a non-empty folder # Cannot prepare a non-empty folder
(fncPath / "foobar.txt").touch() (fncPath / "foobar.txt").touch()
@@ -85,7 +86,7 @@ def testCoreStorage_CreateNewProject(mockGUI, fncPath):
@pytest.mark.core @pytest.mark.core
def testCoreStorage_InitProjectStorage(mockGUI, fncPath, mockRnd): def testCoreStorage_InitProjectStorage(monkeypatch, mockGUI, fncPath, mockRnd):
"""Test initialising a project in a folder.""" """Test initialising a project in a folder."""
project = NWProject() project = NWProject()
@@ -157,6 +158,9 @@ def testCoreStorage_InitProjectStorage(mockGUI, fncPath, mockRnd):
assert isinstance(storage.getDocument(C.hSceneDoc), NWDocument) assert isinstance(storage.getDocument(C.hSceneDoc), NWDocument)
assert repr(storage.getDocument(C.hSceneDoc)) == f"<NWDocument handle={C.hSceneDoc}>" assert repr(storage.getDocument(C.hSceneDoc)) == f"<NWDocument handle={C.hSceneDoc}>"
# We can directly access the content of a document
assert storage.getDocumentText(C.hSceneDoc) == "### New Scene\n\n"
project.closeProject() project.closeProject()
# END Test testCoreStorage_InitProjectStorage # END Test testCoreStorage_InitProjectStorage