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