Add hash and dates to document meta data (#1509)

This commit is contained in:
Veronica Berglyd Olsen
2023-08-30 17:35:51 +01:00
committed by GitHub
51 changed files with 227 additions and 166 deletions
-20
View File
@@ -25,7 +25,6 @@ from __future__ import annotations
import json
import uuid
import hashlib
import logging
import unicodedata
import xml.etree.ElementTree as ET
@@ -488,25 +487,6 @@ def makeFileNameSafe(text: str) -> str:
return "".join(c for c in text if c.isalnum() or c in allowed)
def sha256sum(path: str | Path) -> str | None:
"""Make a shasum of a file using a buffer.
Based on: https://stackoverflow.com/a/44873382/5825851
"""
digest = hashlib.sha256()
bData = bytearray(65536)
mData = memoryview(bData)
try:
with open(path, mode="rb", buffering=0) as inFile:
for n in iter(lambda: inFile.readinto(mData), 0):
digest.update(mData[:n])
except Exception:
logger.error("Could not create sha256sum of: %s", path)
logException()
return None
return digest.hexdigest()
# =============================================================================================== #
# Other Functions
# =============================================================================================== #
+86 -53
View File
@@ -23,15 +23,17 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
"""
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
from novelwriter.core.item import NWItem
if TYPE_CHECKING: # pragma: no cover
from novelwriter.core.project import NWProject
@@ -50,30 +52,38 @@ 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._lastHash = "" # The last known SHA hash
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"<NWDocument handle={self._docHandle}>"
return f"<NWDocument handle={self._handle}>"
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 +91,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 +99,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 +108,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 +121,30 @@ 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
self._lastHash = ""
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)
@@ -143,19 +152,20 @@ class NWDocument:
else:
# The document file does not exist, so we assume it's a new
# document and initialise an empty text string.
# document and return an empty text string.
logger.debug("The requested document does not exist")
return ""
return theText
self._lastHash = 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 +174,45 @@ 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
prevHash = self._lastHash
self.readDocument()
if prevHash and self._lastHash != prevHash and not forceWrite:
logger.error("File has been altered on disk since opened")
self._hashError = True
return False
currTime = formatTimeStamp(time())
writeHash = hashlib.sha1(text.encode()).hexdigest()
createdDate = self._docMeta.get("created", "Unknown")
updatedDate = self._docMeta.get("updated", "Unknown")
if writeHash != self._lastHash:
updatedDate = currTime
if not docPath.is_file():
createdDate = currTime
updatedDate = currTime
# DocMeta Line
if self._theItem is None:
docMeta = ""
else:
docMeta = ""
if self._item:
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 +225,8 @@ class NWDocument:
self._docError = formatException(exc)
return False
self._prevHash = sha256sum(docPath)
self._currHash = self._prevHash
self._lastHash = writeHash
self._hashError = False
return True
@@ -212,7 +235,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 +244,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 +270,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,8 +316,18 @@ 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())
logger.debug("Unknown meta data: '%s'", metaLine.strip())
return
+1 -1
View File
@@ -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?"
+2
View File
@@ -1,6 +1,8 @@
%%~name: John Smith
%%~path: f7e2d9f330615/14298de4d9524
%%~kind: CHARACTER/NOTE
%%~hash: 259eff30f10e101cf93e27e8764e851d356d54b8
%%~date: Unknown/2023-08-25 16:52:03
# John Smith
@tag: John
+2
View File
@@ -1,6 +1,8 @@
%%~name: Title Page
%%~path: 7031beac91f75/53b69b83cdafc
%%~kind: NOVEL/DOCUMENT
%%~hash: c5dc35d18ecb074a9e41a1410d1bff8021cf0a5b
%%~date: Unknown/2023-08-25 16:51:52
#! My Novel
>> **By Jane Smith** <<
+2
View File
@@ -1,6 +1,8 @@
%%~name: Mars
%%~path: 15c4492bd5107/5eaea4e8cdee8
%%~kind: WORLD/NOTE
%%~hash: 0838371bc03e31f503fd12a8617d94ef36e5f7c7
%%~date: Unknown/2023-08-25 16:52:07
# Mars
@tag: Mars
+2
View File
@@ -1,6 +1,8 @@
%%~name: Making a Scene
%%~path: 6a2d6d5f4f401/636b6aa9b697b
%%~kind: NOVEL/DOCUMENT
%%~hash: 053cc65631403c15ddc112849dc7fcae44eb9d63
%%~date: Unknown/2023-08-25 16:56:11
### Making a Scene
@pov: Jane
+2
View File
@@ -1,6 +1,8 @@
%%~name: Chapter One
%%~path: 7031beac91f75/6a2d6d5f4f401
%%~kind: NOVEL/DOCUMENT
%%~hash: 89687756c204742d17f1d37a0ba7fa3aadaf00f2
%%~date: Unknown/2023-08-25 16:51:56
## So it Begins
@pov: Jane
+2
View File
@@ -1,6 +1,8 @@
%%~name: Chapter Two
%%~path: 7031beac91f75/88706ddc78b1b
%%~kind: NOVEL/DOCUMENT
%%~hash: ca68d1134e1b7870b7bf0fcbd8c0d67384970717
%%~date: Unknown/2023-08-25 16:52:00
## Where has John Gone?
@pov: Jane
+2
View File
@@ -1,6 +1,8 @@
%%~name: Old File
%%~path: ae9bf3c3ea159/8a5deb88c0e97
%%~kind: ARCHIVE/DOCUMENT
%%~hash: de9f8f39428e9d127504f47c554fc3e701b0b773
%%~date: Unknown/2023-08-25 16:52:08
### Discarded Scene
If you have files you no longer want in your main project, you can move them to the “Archive” folder. This is equivalent to turning off the “Include when building project” switch, just that you also put the file away.
+2
View File
@@ -1,6 +1,8 @@
%%~name: A Note on Structure
%%~path: 7031beac91f75/96b68994dfa3d
%%~kind: NOVEL/NOTE
%%~hash: f4f88fa2d824ed4e92223bd6744979cd3f9f45e7
%%~date: Unknown/2023-08-25 16:51:59
# A Note on Structure
This file is just a note. You can save notes anywhere you like in the project tree. Notes can be filtered out when you export the project.
+2
View File
@@ -1,6 +1,8 @@
%%~name: Page
%%~path: 7031beac91f75/974e400180a99
%%~kind: NOVEL/DOCUMENT
%%~hash: 7547809e972205eb2a55dd988595e0aa1d01e139
%%~date: Unknown/2023-08-25 16:51:54
[NEW PAGE]
[VSPACE:2]
+2
View File
@@ -1,6 +1,8 @@
%%~name: Chapter One
%%~path: e5e47ebf63b1c/a520879ca0b45
%%~kind: NOVEL/DOCUMENT
%%~hash: 2285256b970dbb3ccf833d9275cd5f8935b6aff1
%%~date: Unknown/2023-08-25 16:52:02
## Chapter One
@pov: Jane
+2
View File
@@ -1,6 +1,8 @@
%%~name: We Found John!
%%~path: 88706ddc78b1b/ae7339df26ded
%%~kind: NOVEL/DOCUMENT
%%~hash: 1da9cf71a57d11c5d94b021a2c3e6a5b8df67f14
%%~date: Unknown/2023-08-25 16:52:01
### We Found John!
@pov: John
+2
View File
@@ -1,6 +1,8 @@
%%~name: Earth
%%~path: 15c4492bd5107/b3e74dbc1f584
%%~kind: WORLD/NOTE
%%~hash: 87f520b4e80af8505af1b868ab25ec6d51b7ff48
%%~date: Unknown/2023-08-25 16:52:05
# Earth
@tag: Earth
+2
View File
@@ -1,6 +1,8 @@
%%~name: Delete Me!
%%~path: 98acd8c76c93a/b8136a5a774a0
%%~kind: TRASH/DOCUMENT
%%~hash: e1eabd80260e03595c8c0a1f82d813d0ae33c273
%%~date: Unknown/2023-08-25 16:52:09
### Delete Me!
This scene is trash.
+2
View File
@@ -1,6 +1,8 @@
%%~name: Interlude
%%~path: 7031beac91f75/ba8a28a246524
%%~kind: NOVEL/DOCUMENT
%%~hash: df8904011eda90c7b694bed725dc774bf631b348
%%~date: Unknown/2023-08-25 16:51:58
##! Interlude
% Notice that this document has a title with a ! in it in addition to the two hash symbols. This means it is an unnumbered chapter. Unnumbered chapters can be treated separately from numbered chapters during export. Perfect for when you want to add an interlude, or for a prologue or epilogue.
+2
View File
@@ -1,6 +1,8 @@
%%~name: Title Page
%%~path: e5e47ebf63b1c/bacb7059e3083
%%~kind: NOVEL/DOCUMENT
%%~hash: 16b55dd0f8dc6bbba266384930a87ec211d471df
%%~date: Unknown/2023-08-25 16:52:01
#! Sequel Novel
>> **By Jane Doh** <<
+2
View File
@@ -1,6 +1,8 @@
%%~name: Jane Smith
%%~path: f7e2d9f330615/bb2c23b3c42cc
%%~kind: CHARACTER/NOTE
%%~hash: a69e4ca6ceede2536c8d364f88e6b62ef0ec36c3
%%~date: Unknown/2023-08-25 16:52:04
# Jane Smith
@tag: Jane
+2
View File
@@ -1,6 +1,8 @@
%%~name: Another Scene
%%~path: 6a2d6d5f4f401/bc0cbd2a407f3
%%~kind: NOVEL/DOCUMENT
%%~hash: b3f9e4b097f7041c77489876577014c435a8be0c
%%~date: Unknown/2023-08-25 16:51:57
### Another Scene
@pov: John
+2
View File
@@ -1,6 +1,8 @@
%%~name: Part One
%%~path: 7031beac91f75/edca4be2fcaf8
%%~kind: NOVEL/DOCUMENT
%%~hash: 9dc53a9bcc457d18b775adf7be38ac89df3408ca
%%~date: Unknown/2023-08-25 16:51:54
# Part One
>> In the beginning … <<
+2
View File
@@ -1,6 +1,8 @@
%%~name: Space
%%~path: 15c4492bd5107/f1471bef9f2ae
%%~kind: WORLD/NOTE
%%~hash: bc139a020239c9c0d936aa9d0bcc3644ae2f370c
%%~date: Unknown/2023-08-25 16:52:06
# Space
@tag: Space
+3 -3
View File
@@ -1,6 +1,6 @@
<?xml version='1.0' encoding='utf-8'?>
<novelWriterXML appVersion="2.1-beta1" hexVersion="0x020100b1" fileVersion="1.5" fileRevision="1" timeStamp="2023-08-08 22:19:23">
<project id="e2be99af-f9bf-4403-857a-c3d1ac25abea" saveCount="1517" autoCount="237" editTime="75241">
<novelWriterXML appVersion="2.1-beta1" hexVersion="0x020100b1" fileVersion="1.5" fileRevision="1" timeStamp="2023-08-25 16:56:30">
<project id="e2be99af-f9bf-4403-857a-c3d1ac25abea" saveCount="1518" autoCount="237" editTime="75281">
<name>Sample Project</name>
<title>Sample Project</title>
<author>Jane Smith</author>
@@ -58,7 +58,7 @@
<name status="sf24ce6" import="ia857f0" active="yes">Chapter One</name>
</item>
<item handle="636b6aa9b697b" parent="6a2d6d5f4f401" root="7031beac91f75" order="0" type="FILE" class="NOVEL" layout="DOCUMENT">
<meta expanded="no" heading="H3" charCount="2687" wordCount="479" paraCount="14" cursorPos="748" />
<meta expanded="no" heading="H3" charCount="2687" wordCount="479" paraCount="14" cursorPos="66" />
<name status="s90e6c9" import="ia857f0" active="yes">Making a Scene</name>
</item>
<item handle="bc0cbd2a407f3" parent="6a2d6d5f4f401" root="7031beac91f75" order="1" type="FILE" class="NOVEL" layout="DOCUMENT">
+1 -2
View File
@@ -226,8 +226,7 @@ def prjLipsum():
@pytest.fixture(scope="session")
def ipsumText():
"""Return five paragraphs of Lorem Ipsum text.
"""
"""Return five paragraphs of Lorem Ipsum text."""
thatIpsum = [(
"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nunc maximus justo non dictum co"
"mmodo. Curabitur lacinia tempor orci vel luctus. Phasellus porta metus eu massa luctus, e"
+2
View File
@@ -1,6 +1,8 @@
%%~name: Ancient Europe
%%~path: 60bdf227455cc/04468803b92e1
%%~kind: WORLD/NOTE
%%~hash: b4318c2de40a1fc4055d18f8d02696b9cf171ea2
%%~date: Unknown/Unknown
# Ancient Europe
@tag: Europe
+2
View File
@@ -1,6 +1,8 @@
%%~name: Main
%%~path: 6c6afb1247750/2426c6f0ca922
%%~kind: PLOT/NOTE
%%~hash: db3897d166e246acdb5e25e9bd98c5a40a699ed0
%%~date: Unknown/Unknown
# Main Plot
@tag: Main
+2
View File
@@ -1,6 +1,8 @@
%%~name: Chapter Two
%%~path: 6bd935d2490cd/441420a886d82
%%~kind: NOVEL/DOCUMENT
%%~hash: fd6d46708faa1333f8f7ba0442fc5b35bf1e3f85
%%~date: Unknown/Unknown
## Chapter Two
@pov: Bod
+2
View File
@@ -1,6 +1,8 @@
%%~name: Scene Five
%%~path: 6bd935d2490cd/47666c91c7ccf
%%~kind: NOVEL/DOCUMENT
%%~hash: d210c26966da6f9edea8726567abf4860b7bb9b7
%%~date: Unknown/Unknown
### Scene Five
@pov: Bod
+2
View File
@@ -1,6 +1,8 @@
%%~name: Mr. Nobody
%%~path: 67a8707f2f249/4c4f28287af27
%%~kind: CHARACTER/NOTE
%%~hash: 07a86c2001669de7d7a7545286c22b963e3ccf63
%%~date: Unknown/Unknown
# Nobody Owens
@tag: Bod
+2
View File
@@ -1,6 +1,8 @@
%%~name: Lorem Ipsum
%%~path: b3643d0f92e32/7a992350f3eb6
%%~kind: NOVEL/DOCUMENT
%%~hash: 8efda028000b70be0d7dbe9647b6026082ef05c9
%%~date: Unknown/Unknown
#! Lorem Ipsum
>> **By lipsum.com** <<
+2
View File
@@ -1,6 +1,8 @@
%%~name: Interlude
%%~path: b3643d0f92e32/846352075de7d
%%~kind: NOVEL/DOCUMENT
%%~hash: ac0e16c65142b9f1e0fa281bdc9b954e44026740
%%~date: Unknown/Unknown
##! Why do we use it?
% Exctracted from the lipsum.com website.
+2
View File
@@ -1,6 +1,8 @@
%%~name: Scene One
%%~path: 45e6b01ca35c1/88243afbe5ed8
%%~kind: NOVEL/DOCUMENT
%%~hash: a09245a7a772bbe02850b5db109977e336cd9cc1
%%~date: Unknown/Unknown
### Scene One
@pov: Bod
+2
View File
@@ -1,6 +1,8 @@
%%~name: Prologue
%%~path: b3643d0f92e32/88d59a277361b
%%~kind: NOVEL/DOCUMENT
%%~hash: 19a2aa95b07ce10eaea753c46569929439648c63
%%~date: Unknown/Unknown
##! Prologue
% Synopsis:Explanation from the lipsum.com website.
+2
View File
@@ -1,6 +1,8 @@
%%~name: Front Matter
%%~path: b3643d0f92e32/8c58a65414c23
%%~kind: NOVEL/DOCUMENT
%%~hash: 5c3961cb7616ef2b378010f38a89ed268ef15d92
%%~date: Unknown/Unknown
[NEW PAGE]
% Exctracted from the lipsum.com website.
+2
View File
@@ -1,6 +1,8 @@
%%~name: Act One
%%~path: b3643d0f92e32/db7e733775d4d
%%~kind: NOVEL/DOCUMENT
%%~hash: d93cd4c96d49e4afd93c82cca29d413a35012108
%%~date: Unknown/Unknown
# Act One
>> “Fusce maximus felis libero” <<
+2
View File
@@ -1,6 +1,8 @@
%%~name: Scene Three
%%~path: 6bd935d2490cd/eb103bc70c90c
%%~kind: NOVEL/DOCUMENT
%%~hash: c4eda49e4fe81dc450d547eee0bdabe77fdaaa98
%%~date: Unknown/Unknown
### Scene Three
@pov: Bod
+2
View File
@@ -1,6 +1,8 @@
%%~name: Scene Four
%%~path: 6bd935d2490cd/f8c0562e50f1b
%%~kind: NOVEL/DOCUMENT
%%~hash: 9461a279b9fb6ef005ee4d432fcda77ff5bfbd42
%%~date: Unknown/Unknown
### Scene Four
@pov: Bod
+2
View File
@@ -1,6 +1,8 @@
%%~name: Scene Two
%%~path: 45e6b01ca35c1/f96ec11c6a3da
%%~kind: NOVEL/DOCUMENT
%%~hash: ebe3fbaa16d9d81bc1a139822e3bf39bb357866d
%%~date: Unknown/Unknown
### Scene Two
@pov: Bod
+2
View File
@@ -1,6 +1,8 @@
%%~name: Chapter One
%%~path: 45e6b01ca35c1/fb609cd8319dc
%%~kind: NOVEL/DOCUMENT
%%~hash: 5dabeaa7a58238a6ad99ce73176b1bdfad1a71b7
%%~date: Unknown/Unknown
## Chapter One
@pov: Bod
+2 -2
View File
@@ -1,6 +1,6 @@
<?xml version='1.0' encoding='utf-8'?>
<novelWriterXML appVersion="2.0.7" hexVersion="0x020007f0" fileVersion="1.5" fileRevision="1" timeStamp="2023-06-03 18:18:14">
<project id="5ac0df12-8b8b-476b-9905-21d33685687c" saveCount="42" autoCount="24" editTime="1913">
<novelWriterXML appVersion="2.1-beta1" hexVersion="0x020100b1" fileVersion="1.5" fileRevision="1" timeStamp="2023-08-25 18:03:37">
<project id="5ac0df12-8b8b-476b-9905-21d33685687c" saveCount="43" autoCount="24" editTime="1935">
<name>Lorem Ipsum</name>
<title>Lorem Ipsum</title>
<author>lipsum.com</author>
@@ -1,6 +1,8 @@
%%~name: Chapter 1
%%~path: 0000000000008/0000000000010
%%~kind: NOVEL/DOCUMENT
%%~hash: 25af8567c8a3b3b49a27dcda2999ed1f8811955a
%%~date: 2023-08-25 18:32:30/2023-08-25 18:32:30
## Chapter 1
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nunc maximus justo non dictum commodo. Curabitur lacinia tempor orci vel luctus. Phasellus porta metus eu massa luctus, eget euismod risus rhoncus. Vestibulum sed arcu nisi. Maecenas pretium facilisis velit, vel semper lacus aliquam sit amet. Vestibulum vulputate neque ligula, rhoncus blandit turpis consequat id. Mauris sagittis vehicula imperdiet. Duis sed nunc pretium, ornare purus vel, sodales augue. Maecenas a suscipit risus. Quisque volutpat justo eleifend est ullamcorper fermentum. Donec ullamcorper et tortor a laoreet. Nam id risus nisi. Vivamus non imperdiet erat, sit amet imperdiet felis. Mauris vitae neque et est aliquam scelerisque non non ipsum.
@@ -1,6 +1,8 @@
%%~name: All of Chapter 1
%%~path: 0000000000008/0000000000014
%%~kind: NOVEL/DOCUMENT
%%~hash: 9e632e7e29860572c685da8501b92bf99825ac3b
%%~date: 2023-08-25 18:29:39/2023-08-25 18:29:39
% Merge Novel Chapter: Chapter 1 [New]
## Chapter 1
@@ -1,6 +1,8 @@
%%~name: New Scene
%%~path: 000000000000d/000000000000f
%%~kind: NOVEL/DOCUMENT
%%~hash: fd5dc2f0c9767cb124b1bf2300d7a33b7780045e
%%~date: 2023-08-25 18:08:01/2023-08-25 18:08:04
# Novel
## Chapter
@@ -1,6 +1,8 @@
%%~name: New Note
%%~path: 000000000000a/0000000000010
%%~kind: CHARACTER/NOTE
%%~hash: 9fae6dfdd3d1c0822d3a3cf90c0142e65ad8e557
%%~date: 2023-08-25 18:14:24/2023-08-25 18:14:24
# Jane Doe
@tag: Jane
@@ -1,6 +1,8 @@
%%~name: New Note
%%~path: 0000000000009/0000000000011
%%~kind: PLOT/NOTE
%%~hash: 8ff26f8a18ad6390c2ce725c441ab0c792a125cf
%%~date: 2023-08-25 18:15:35/2023-08-25 18:15:35
# Main Plot
@tag: MainPlot
@@ -1,6 +1,8 @@
%%~name: New Note
%%~path: 000000000000b/0000000000012
%%~kind: WORLD/NOTE
%%~hash: 3f5c3c6c3ba1c27c30b8ac9e59c222fb9a1bd775
%%~date: 2023-08-25 18:17:45/2023-08-25 18:17:45
# Main Location
@tag: Home
+2 -42
View File
@@ -21,7 +21,6 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
import time
import pytest
import hashlib
from pathlib import Path
from xml.etree import ElementTree as ET
@@ -35,8 +34,8 @@ from novelwriter.common import (
checkString, checkStringNone, checkUuid, formatInt, formatTime,
formatTimeStamp, fuzzyTime, getGuiItem, hexToInt, isHandle, isItemClass,
isItemLayout, isItemType, isTitleTag, jsonEncode, makeFileNameSafe, minmax,
numberToRoman, NWConfigParser, readTextFile, sha256sum, simplified,
transferCase, xmlIndent, yesNo
numberToRoman, NWConfigParser, readTextFile, simplified, transferCase,
xmlIndent, yesNo
)
@@ -622,45 +621,6 @@ def testBaseCommon_makeFileNameSafe():
# END Test testBaseCommon_makeFileNameSafe
@pytest.mark.base
def testBaseCommon_sha256sum(monkeypatch, fncPath, ipsumText):
"""Test the sha256sum function."""
longText = 50*(" ".join(ipsumText) + " ")
shortText = "This is a short file"
noneText = ""
assert len(longText) == 175650
longFile = fncPath / "long_file.txt"
shortFile = fncPath / "short_file.txt"
noneFile = fncPath / "none_file.txt"
writeFile(longFile, longText)
writeFile(shortFile, shortText)
writeFile(noneFile, noneText)
# Taken with sha256sum command on command line
longHash = "9b22aee35660da4fae204acbe96aec7f563022746ca2b7a3831f5e44544765eb"
shortHash = "6d7c9b2722364c471b8a8666bcb35d18500272d05b23b3427288e2e34c6618f0"
noneHash = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
assert sha256sum(longFile) == longHash
assert sha256sum(shortFile) == shortHash
assert sha256sum(noneFile) == noneHash
assert hashlib.sha256(longText.encode("utf-8")).hexdigest() == longHash
assert hashlib.sha256(shortText.encode("utf-8")).hexdigest() == shortHash
assert hashlib.sha256(noneText.encode("utf-8")).hexdigest() == noneHash
with monkeypatch.context() as mp:
mp.setattr("builtins.open", causeOSError)
assert sha256sum(longFile) is None
assert sha256sum(shortFile) is None
assert sha256sum(noneFile) is None
# END Test testBaseCommon_sha256sum
@pytest.mark.base
def testBaseCommon_getGuiItem(nwGUI):
"""Check the GUI item function."""
+27 -27
View File
@@ -26,8 +26,8 @@ from shutil import copyfile
from pathlib import Path
from zipfile import ZipFile
from tools import C, NWD_IGNORE, buildTestProject, cmpFiles, XML_IGNORE
from mocked import causeOSError
from tools import C, buildTestProject, cmpFiles, XML_IGNORE
from novelwriter import CONFIG
from novelwriter.constants import nwItemClass
@@ -46,19 +46,19 @@ def testCoreTools_DocMerger(monkeypatch, mockGUI, fncPath, tstPaths, mockRnd, ip
# =====================
hChapter1 = theProject.newFile("Chapter 1", C.hNovelRoot)
hSceneOne11 = theProject.newFile("Scene 1.1", hChapter1)
hSceneOne12 = theProject.newFile("Scene 1.2", hChapter1)
hSceneOne13 = theProject.newFile("Scene 1.3", hChapter1)
hSceneOne11 = theProject.newFile("Scene 1.1", hChapter1) # type: ignore
hSceneOne12 = theProject.newFile("Scene 1.2", hChapter1) # type: ignore
hSceneOne13 = theProject.newFile("Scene 1.3", hChapter1) # type: ignore
docText1 = "\n\n".join(ipsumText[0:2]) + "\n\n"
docText2 = "\n\n".join(ipsumText[1:3]) + "\n\n"
docText3 = "\n\n".join(ipsumText[2:4]) + "\n\n"
docText4 = "\n\n".join(ipsumText[3:5]) + "\n\n"
theProject.writeNewFile(hChapter1, 2, True, docText1)
theProject.writeNewFile(hSceneOne11, 3, True, docText2)
theProject.writeNewFile(hSceneOne12, 3, True, docText3)
theProject.writeNewFile(hSceneOne13, 3, True, docText4)
theProject.writeNewFile(hChapter1, 2, True, docText1) # type: ignore
theProject.writeNewFile(hSceneOne11, 3, True, docText2) # type: ignore
theProject.writeNewFile(hSceneOne12, 3, True, docText3) # type: ignore
theProject.writeNewFile(hSceneOne13, 3, True, docText4) # type: ignore
# Basic Checks
# ============
@@ -81,12 +81,12 @@ def testCoreTools_DocMerger(monkeypatch, mockGUI, fncPath, tstPaths, mockRnd, ip
testFile = tstPaths.outDir / "coreDocTools_DocMerger_0000000000014.nwd"
compFile = tstPaths.refDir / "coreDocTools_DocMerger_0000000000014.nwd"
assert docMerger.newTargetDoc(hChapter1, "All of Chapter 1") == "0000000000014"
assert docMerger.newTargetDoc(hChapter1, "All of Chapter 1") == "0000000000014" # type: ignore
assert docMerger.appendText(hChapter1, True, "Merge") is True
assert docMerger.appendText(hSceneOne11, True, "Merge") is True
assert docMerger.appendText(hSceneOne12, True, "Merge") is True
assert docMerger.appendText(hSceneOne13, True, "Merge") is True
assert docMerger.appendText(hChapter1, True, "Merge") is True # type: ignore
assert docMerger.appendText(hSceneOne11, True, "Merge") is True # type: ignore
assert docMerger.appendText(hSceneOne12, True, "Merge") is True # type: ignore
assert docMerger.appendText(hSceneOne13, True, "Merge") is True # type: ignore
# Block writing and check error handling
with monkeypatch.context() as mp:
@@ -98,7 +98,7 @@ def testCoreTools_DocMerger(monkeypatch, mockGUI, fncPath, tstPaths, mockRnd, ip
# Write properly, and compare
assert docMerger.writeTargetDoc() is True
copyfile(saveFile, testFile)
assert cmpFiles(testFile, compFile)
assert cmpFiles(testFile, compFile, ignoreStart=NWD_IGNORE)
# Merge into Existing
# ===================
@@ -107,15 +107,15 @@ def testCoreTools_DocMerger(monkeypatch, mockGUI, fncPath, tstPaths, mockRnd, ip
testFile = tstPaths.outDir / "coreDocTools_DocMerger_0000000000010.nwd"
compFile = tstPaths.refDir / "coreDocTools_DocMerger_0000000000010.nwd"
docMerger.setTargetDoc(hChapter1)
docMerger.setTargetDoc(hChapter1) # type: ignore
assert docMerger.appendText(hSceneOne11, True, "Merge") is True
assert docMerger.appendText(hSceneOne12, True, "Merge") is True
assert docMerger.appendText(hSceneOne13, True, "Merge") is True
assert docMerger.appendText(hSceneOne11, True, "Merge") is True # type: ignore
assert docMerger.appendText(hSceneOne12, True, "Merge") is True # type: ignore
assert docMerger.appendText(hSceneOne13, True, "Merge") is True # type: ignore
assert docMerger.writeTargetDoc() is True
copyfile(saveFile, testFile)
assert cmpFiles(testFile, compFile)
assert cmpFiles(testFile, compFile, ignoreStart=NWD_IGNORE)
# Just for debugging
docMerger.writeTargetDoc()
@@ -163,11 +163,11 @@ def testCoreTools_DocSplitter(monkeypatch, mockGUI, fncPath, mockRnd, ipsumText)
docText = "\n\n".join(docData)
docRaw = docText.splitlines()
assert theProject.storage.getDocument(hSplitDoc).writeDocument(docText) is True
theProject.tree[hSplitDoc].setStatus(C.sFinished)
theProject.tree[hSplitDoc].setImport(C.iMain)
theProject.tree[hSplitDoc].setStatus(C.sFinished) # type: ignore
theProject.tree[hSplitDoc].setImport(C.iMain) # type: ignore
docSplitter = DocSplitter(theProject, hSplitDoc)
assert docSplitter._srcItem.isFileType()
docSplitter = DocSplitter(theProject, hSplitDoc) # type: ignore
assert docSplitter._srcItem.isFileType() # type: ignore
assert docSplitter.getError() == ""
# Run the split algorithm
@@ -247,8 +247,8 @@ def testCoreTools_DocSplitter(monkeypatch, mockGUI, fncPath, mockRnd, ipsumText)
# Check that status and importance has been preserved
for rHandle in resDocHandle:
assert theProject.tree[rHandle].itemStatus == C.sFinished
assert theProject.tree[rHandle].itemImport == C.iMain
assert theProject.tree[rHandle].itemStatus == C.sFinished # type: ignore
assert theProject.tree[rHandle].itemImport == C.iMain # type: ignore
# Check handling of improper initialisation
docSplitter = DocSplitter(theProject, C.hInvalid)
@@ -416,7 +416,7 @@ def testCoreTools_NewMinimal(monkeypatch, fncPath, tstPaths, mockGUI, mockRnd):
assert projBuild.buildProject({}) is False
# Wrong type should also fail
assert projBuild.buildProject("stuff") is False
assert projBuild.buildProject("stuff") is False # type: ignore
# Try again with a proper path
assert projBuild.buildProject({"projPath": fncPath}) is True
@@ -508,7 +508,7 @@ def testCoreTools_NewCustomB(monkeypatch, fncPath, tstPaths, mockRnd):
@pytest.mark.core
def testCoreTools_NewSample(monkeypatch, fncPath, tstPaths):
def testCoreTools_NewSample(monkeypatch, mockGUI, fncPath, tstPaths):
"""Check that we can create a new project can be created from the
provided sample project via a zip file.
"""
+16 -9
View File
@@ -21,8 +21,8 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
import pytest
from tools import C, MOCK_TIME, buildTestProject, readFile, writeFile
from mocked import causeOSError
from tools import C, buildTestProject, readFile, writeFile
from novelwriter.enum import nwItemClass, nwItemLayout
from novelwriter.core.project import NWProject
@@ -32,6 +32,8 @@ from novelwriter.core.document import NWDocument
@pytest.mark.core
def testCoreDocument_LoadSave(monkeypatch, mockGUI, fncPath, mockRnd):
"""Test loading and saving a document with the NWDocument class."""
monkeypatch.setattr("novelwriter.core.document.time", lambda: MOCK_TIME)
theProject = NWProject()
mockRnd.reset()
buildTestProject(theProject, fncPath)
@@ -48,7 +50,7 @@ def testCoreDocument_LoadSave(monkeypatch, mockGUI, fncPath, mockRnd):
# Non-existent handle
theDoc = NWDocument(theProject, C.hInvalid)
assert theDoc.readDocument() is None
assert theDoc._currHash is None
assert theDoc._lastHash == ""
assert theDoc.fileExists() is False
# No content path
@@ -90,7 +92,7 @@ def testCoreDocument_LoadSave(monkeypatch, mockGUI, fncPath, mockRnd):
# Set handle and save
theText = "### Test File\n\nText ...\n\n"
theDoc = NWDocument(theProject, xHandle)
assert theDoc.readDocument(xHandle) == ""
assert theDoc.readDocument(xHandle) == "" # type: ignore
assert theDoc.writeDocument(theText) is True
# Save again to ensure temp file and previous file is handled
@@ -102,6 +104,8 @@ def testCoreDocument_LoadSave(monkeypatch, mockGUI, fncPath, mockRnd):
"%%~name: New File\n"
f"%%~path: {C.hNovelRoot}/{xHandle}\n"
"%%~kind: NOVEL/DOCUMENT\n"
"%%~hash: b288c3ab03181027d9a16d7fd2291262f5de9ac8\n"
"%%~date: 2019-05-10 18:52:00/2019-05-10 18:52:00\n"
"### Test File\n\n"
"Text ...\n\n"
)
@@ -114,7 +118,7 @@ def testCoreDocument_LoadSave(monkeypatch, mockGUI, fncPath, mockRnd):
assert theDoc.writeDocument(theText, forceWrite=True) is True
# Force no meta data
theDoc._theItem = None
theDoc._item = None
assert theDoc.writeDocument(theText) is True
assert readFile(docPath) == theText
@@ -137,7 +141,7 @@ def testCoreDocument_LoadSave(monkeypatch, mockGUI, fncPath, mockRnd):
assert theDoc.getError() == ""
# Saving with no handle
theDoc._docHandle = None
theDoc._handle = None
assert theDoc.writeDocument(theText) is False
# Delete Document
@@ -170,9 +174,10 @@ def testCoreDocument_LoadSave(monkeypatch, mockGUI, fncPath, mockRnd):
@pytest.mark.core
def testCoreDocument_Methods(mockGUI, fncPath, mockRnd):
"""Test other methods of the NWDocument class.
"""
def testCoreDocument_Methods(monkeypatch, mockGUI, fncPath, mockRnd):
"""Test other methods of the NWDocument class."""
monkeypatch.setattr("novelwriter.core.document.time", lambda: MOCK_TIME)
theProject = NWProject()
mockRnd.reset()
buildTestProject(theProject, fncPath)
@@ -187,7 +192,7 @@ def testCoreDocument_Methods(mockGUI, fncPath, mockRnd):
# Check the item
assert theDoc.getCurrentItem() is not None
assert theDoc.getCurrentItem().itemHandle == C.hSceneDoc
assert theDoc.getCurrentItem().itemHandle == C.hSceneDoc # type: ignore
# Check the meta
theName, theParent, theClass, theLayout = theDoc.getMeta()
@@ -202,6 +207,8 @@ def testCoreDocument_Methods(mockGUI, fncPath, mockRnd):
"%%~name: New Scene\n"
f"%%~path: {C.hChapterDir}/{C.hSceneDoc}\n"
"%%~kind: NOVEL/DOCUMENT\n"
"%%~hash: dd350c602de803554b2a7c17f191ae25dea1df63\n"
"%%~date: 2019-05-10 18:52:00/2019-05-10 18:52:00\n"
"%%~ stuff\n"
"### Test File\n\n"
"Text ...\n\n"
+5 -5
View File
@@ -24,7 +24,7 @@ import pytest
from shutil import copyfile
from tools import (
C, cmpFiles, buildTestProject, XML_IGNORE, getGuiItem, writeFile
C, NWD_IGNORE, cmpFiles, buildTestProject, XML_IGNORE, getGuiItem, writeFile
)
from PyQt5.QtCore import Qt
@@ -529,25 +529,25 @@ def testGuiMain_Editing(qtbot, monkeypatch, nwGUI, projPath, tstPaths, mockRnd):
testFile = tstPaths.outDir / "guiEditor_Main_Final_000000000000f.nwd"
compFile = tstPaths.refDir / "guiEditor_Main_Final_000000000000f.nwd"
copyfile(projFile, testFile)
assert cmpFiles(testFile, compFile)
assert cmpFiles(testFile, compFile, ignoreStart=NWD_IGNORE)
projFile = projPath / "content" / "0000000000010.nwd"
testFile = tstPaths.outDir / "guiEditor_Main_Final_0000000000010.nwd"
compFile = tstPaths.refDir / "guiEditor_Main_Final_0000000000010.nwd"
copyfile(projFile, testFile)
assert cmpFiles(testFile, compFile)
assert cmpFiles(testFile, compFile, ignoreStart=NWD_IGNORE)
projFile = projPath / "content" / "0000000000011.nwd"
testFile = tstPaths.outDir / "guiEditor_Main_Final_0000000000011.nwd"
compFile = tstPaths.refDir / "guiEditor_Main_Final_0000000000011.nwd"
copyfile(projFile, testFile)
assert cmpFiles(testFile, compFile)
assert cmpFiles(testFile, compFile, ignoreStart=NWD_IGNORE)
projFile = projPath / "content" / "0000000000012.nwd"
testFile = tstPaths.outDir / "guiEditor_Main_Final_0000000000012.nwd"
compFile = tstPaths.refDir / "guiEditor_Main_Final_0000000000012.nwd"
copyfile(projFile, testFile)
assert cmpFiles(testFile, compFile)
assert cmpFiles(testFile, compFile, ignoreStart=NWD_IGNORE)
# qtbot.stop()
+4 -2
View File
@@ -23,11 +23,14 @@ from __future__ import annotations
import shutil
from pathlib import Path
from datetime import datetime
from PyQt5.QtWidgets import qApp
XML_IGNORE = ("<novelWriterXML", "<project")
ODT_IGNORE = ("<meta:generator", "<meta:creation-date", "<dc:date", "<meta:editing")
NWD_IGNORE = ("%%~date:",)
MOCK_TIME = datetime(2019, 5, 10, 18, 52, 0).timestamp()
class C:
@@ -62,8 +65,7 @@ def cmpFiles(
ignoreLines: list | None = None,
ignoreStart: tuple | None = None
) -> bool:
"""Compare two files, but optionally ignore lines given by a list.
"""
"""Compare two files, with optional line ignore."""
if ignoreLines is None:
ignoreLines = []