Change how text hash is calculated in documents, and add dates
This commit is contained in:
@@ -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())
|
||||||
|
|
||||||
|
|||||||
@@ -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?"
|
||||||
|
|||||||
Reference in New Issue
Block a user