Change how text hash is calculated in documents, and add dates

This commit is contained in:
Veronica Berglyd Olsen
2023-08-25 16:55:08 +02:00
parent 1b98bdf28f
commit b3fcea29da
2 changed files with 80 additions and 48 deletions
+79 -47
View File
@@ -23,15 +23,17 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
""" """
from __future__ import annotations from __future__ import annotations
import hashlib
import logging import logging
from time import time
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from pathlib import Path from pathlib import Path
from novelwriter.core.item import NWItem from novelwriter.core.item import NWItem
from novelwriter.enum import nwItemLayout, nwItemClass from novelwriter.enum import nwItemLayout, nwItemClass
from novelwriter.error import formatException from novelwriter.error import formatException
from novelwriter.common import isHandle, sha256sum from novelwriter.common import formatTimeStamp, isHandle
if TYPE_CHECKING: # pragma: no cover if TYPE_CHECKING: # pragma: no cover
from novelwriter.core.project import NWProject from novelwriter.core.project import NWProject
@@ -50,30 +52,39 @@ class NWDocument:
def __init__(self, project: NWProject, tHandle: str | None) -> None: def __init__(self, project: NWProject, tHandle: str | None) -> None:
self._project = project self._project = project
# Internal Variables self._item = None # The currently open item
self._theItem = None # The currently open item self._handle = None # The handle of the currently open item
self._docHandle = None # The handle of the currently open item self._fileLoc = None # The file location of the currently open item
self._fileLoc = None # The file location of the currently open item self._docMeta = {} # The meta data of the currently open item
self._docMeta = {} # The meta data of the currently open item self._docError = "" # The latest encountered IO error
self._docError = "" # The latest encountered IO error self._readHash = "" # The SHA hash on last read
self._prevHash = None # Previous sha256sum of the document file self._writeHash = "" # The SHA hash on last write
self._currHash = None # Latest sha256sum of the document file self._hashError = False # Hash mismatch on last write attempt
if isHandle(tHandle): if isHandle(tHandle):
self._docHandle = tHandle self._handle = tHandle
if self._docHandle is not None: if self._handle is not None:
self._theItem = self._project.tree[tHandle] self._item = self._project.tree[tHandle]
return return
def __repr__(self) -> str: def __repr__(self) -> str:
return f"<NWDocument handle={self._docHandle}>" return f"<NWDocument handle={self._handle}>"
def __bool__(self) -> bool: def __bool__(self) -> bool:
return self._docHandle is not None and bool(self._theItem) return self._handle is not None and self._item is not None
##
# Properties
##
@property
def hashError(self) -> bool:
"""Check if the file hash has changed outside of novelWriter."""
return self._hashError
## ##
# Class Methods # Class Methods
@@ -81,7 +92,7 @@ class NWDocument:
def fileExists(self) -> bool: def fileExists(self) -> bool:
"""Check if the document file exists.""" """Check if the document file exists."""
if self._docHandle is None: if self._handle is None:
return False return False
contentPath = self._project.storage.contentPath contentPath = self._project.storage.contentPath
@@ -89,7 +100,7 @@ class NWDocument:
logger.error("No content path set") logger.error("No content path set")
return False return False
return (contentPath / f"{self._docHandle}.nwd").is_file() return (contentPath / f"{self._handle}.nwd").is_file()
def readDocument(self, isOrphan: bool = False) -> str | None: def readDocument(self, isOrphan: bool = False) -> str | None:
"""Read the document specified by the handle set in the """Read the document specified by the handle set in the
@@ -98,11 +109,11 @@ class NWDocument:
empty string. If something went wrong, return None. empty string. If something went wrong, return None.
""" """
self._docError = "" self._docError = ""
if not isinstance(self._docHandle, str): if not isinstance(self._handle, str):
logger.error("No document handle set") logger.error("No document handle set")
return None return None
if self._theItem is None and not isOrphan: if self._item is None and not isOrphan:
logger.error("Unknown novelWriter document") logger.error("Unknown novelWriter document")
return None return None
@@ -111,31 +122,29 @@ class NWDocument:
logger.error("No content path set") logger.error("No content path set")
return None return None
docFile = f"{self._docHandle}.nwd" docFile = f"{self._handle}.nwd"
logger.debug("Opening document: %s", docFile) logger.debug("Opening document: %s", docFile)
docPath = contentPath / docFile docPath = contentPath / docFile
self._fileLoc = docPath self._fileLoc = docPath
theText = "" text = ""
self._docMeta = {} self._docMeta = {}
self._prevHash = None
if docPath.exists(): if docPath.exists():
self._prevHash = sha256sum(docPath)
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 i in range(10):
inLine = inFile.readline() line = inFile.readline()
if inLine.startswith(r"%%~"): if line.startswith(r"%%~"):
self._parseMeta(inLine) self._parseMeta(line)
else: else:
theText = inLine text = line
break break
# Load the rest of the file # Load the rest of the file
theText += inFile.read() text += inFile.read()
except Exception as exc: except Exception as exc:
self._docError = formatException(exc) self._docError = formatException(exc)
@@ -147,15 +156,17 @@ class NWDocument:
logger.debug("The requested document does not exist") logger.debug("The requested document does not exist")
return "" return ""
return theText self._readHash = hashlib.sha1(text.encode()).hexdigest()
def writeDocument(self, docText: str, forceWrite: bool = False) -> bool: return text
def writeDocument(self, text: str, forceWrite: bool = False) -> bool:
"""Write the document specified by the handle attribute. Handle """Write the document specified by the handle attribute. Handle
any IO errors in the process Returns True if successful, False any IO errors in the process Returns True if successful, False
if not. if not.
""" """
self._docError = "" self._docError = ""
if not isinstance(self._docHandle, str): if not isinstance(self._handle, str):
logger.error("No document handle set") logger.error("No document handle set")
return False return False
@@ -164,32 +175,43 @@ class NWDocument:
logger.error("No content path set") logger.error("No content path set")
return False return False
docFile = f"{self._docHandle}.nwd" docFile = f"{self._handle}.nwd"
logger.debug("Saving document: %s", docFile) logger.debug("Saving document: %s", docFile)
docPath = contentPath / docFile docPath = contentPath / docFile
docTemp = docPath.with_suffix(".tmp") docTemp = docPath.with_suffix(".tmp")
if self._prevHash is not None and not forceWrite: # Re-read the document on disk to check if it has changed
self._currHash = sha256sum(docPath) self.readDocument()
if self._currHash is not None and self._currHash != self._prevHash: if self._writeHash and self._writeHash != self._readHash and not forceWrite:
logger.error("File has been altered on disk since opened") logger.error("File has been altered on disk since opened")
return False self._hashError = True
return False
writeHash = hashlib.sha1(text.encode()).hexdigest()
createdDate = self._docMeta.get("created", "Unknown")
updatedDate = self._docMeta.get("updated", "Unknown")
if writeHash != self._writeHash:
updatedDate = formatTimeStamp(time())
if not docPath.is_file():
createdDate = updatedDate
# DocMeta Line # DocMeta Line
if self._theItem is None: if self._item is None:
docMeta = "" docMeta = ""
else: else:
docMeta = ( docMeta = (
f"%%~name: {self._theItem.itemName}\n" f"%%~name: {self._item.itemName}\n"
f"%%~path: {self._theItem.itemParent}/{self._theItem.itemHandle}\n" f"%%~path: {self._item.itemParent}/{self._item.itemHandle}\n"
f"%%~kind: {self._theItem.itemClass.name}/{self._theItem.itemLayout.name}\n" f"%%~kind: {self._item.itemClass.name}/{self._item.itemLayout.name}\n"
f"%%~hash: {writeHash}\n"
f"%%~date: {createdDate}/{updatedDate}\n"
) )
try: try:
with open(docTemp, mode="w", encoding="utf-8") as outFile: with open(docTemp, mode="w", encoding="utf-8") as outFile:
outFile.write(docMeta) outFile.write(docMeta)
outFile.write(docText) outFile.write(text)
except Exception as exc: except Exception as exc:
self._docError = formatException(exc) self._docError = formatException(exc)
return False return False
@@ -202,8 +224,8 @@ class NWDocument:
self._docError = formatException(exc) self._docError = formatException(exc)
return False return False
self._prevHash = sha256sum(docPath) self._writeHash = writeHash
self._currHash = self._prevHash self._hashError = False
return True return True
@@ -212,7 +234,7 @@ class NWDocument:
from the project data folder. from the project data folder.
""" """
self._docError = "" self._docError = ""
if not isinstance(self._docHandle, str): if not isinstance(self._handle, str):
logger.error("No document handle set") logger.error("No document handle set")
return False return False
@@ -221,7 +243,7 @@ class NWDocument:
logger.error("No content path set") logger.error("No content path set")
return False return False
docPath = contentPath / f"{self._docHandle}.nwd" docPath = contentPath / f"{self._handle}.nwd"
docTemp = docPath.with_suffix(".tmp") docTemp = docPath.with_suffix(".tmp")
try: try:
@@ -247,7 +269,7 @@ class NWDocument:
def getCurrentItem(self) -> NWItem | None: def getCurrentItem(self) -> NWItem | None:
"""Return a pointer to the currently open NWItem.""" """Return a pointer to the currently open NWItem."""
return self._theItem return self._item
def getMeta(self) -> tuple[str, str | None, nwItemClass | None, nwItemLayout | None]: def getMeta(self) -> tuple[str, str | None, nwItemClass | None, nwItemLayout | None]:
"""Parse the document meta tag and return the name, parent, """Parse the document meta tag and return the name, parent,
@@ -293,6 +315,16 @@ class NWDocument:
if metaBits[1] in nwItemLayout.__members__: if metaBits[1] in nwItemLayout.__members__:
self._docMeta["layout"] = nwItemLayout[metaBits[1]] self._docMeta["layout"] = nwItemLayout[metaBits[1]]
elif metaLine.startswith("%%~hash:"):
self._docMeta["hash"] = metaLine[8:].strip()
elif metaLine.startswith("%%~date:"):
metaVal = metaLine[8:].strip()
metaBits = metaVal.split("/")
if len(metaBits) == 2:
self._docMeta["created"] = metaBits[0].strip()
self._docMeta["updated"] = metaBits[1].strip()
else: else:
logger.debug("Ignoring meta data: '%s'", metaLine.strip()) logger.debug("Ignoring meta data: '%s'", metaLine.strip())
+1 -1
View File
@@ -520,7 +520,7 @@ class GuiDocEditor(QTextEdit):
self.saveCursorPosition() self.saveCursorPosition()
if not self._nwDocument.writeDocument(docText): if not self._nwDocument.writeDocument(docText):
saveOk = False saveOk = False
if self._nwDocument._currHash != self._nwDocument._prevHash: if self._nwDocument.hashError:
msgYes = SHARED.question(self.tr( msgYes = SHARED.question(self.tr(
"This document has been changed outside of novelWriter " "This document has been changed outside of novelWriter "
"while it was open. Overwrite the file on disk?" "while it was open. Overwrite the file on disk?"