From a760aff826567bd607b8a818e32aa9c27d52602f Mon Sep 17 00:00:00 2001
From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com>
Date: Mon, 31 Oct 2022 20:05:20 +0100
Subject: [PATCH] Complete the xml writer class
---
novelwriter/core/item.py | 76 ++++----
novelwriter/core/project.py | 109 +----------
novelwriter/core/projectxml.py | 174 ++++++++++++++++--
novelwriter/core/status.py | 28 ++-
novelwriter/core/tree.py | 13 +-
.../coreProject_NewFileFolder_nwProject.nwx | 8 +-
.../coreProject_NewRoot_nwProject.nwx | 8 +-
tests/test_core/test_core_project.py | 26 ---
8 files changed, 220 insertions(+), 222 deletions(-)
diff --git a/novelwriter/core/item.py b/novelwriter/core/item.py
index e71d0643..eae4813a 100644
--- a/novelwriter/core/item.py
+++ b/novelwriter/core/item.py
@@ -25,8 +25,6 @@ along with this program. If not, see .
import logging
-from lxml import etree
-
from novelwriter.enum import nwItemType, nwItemClass, nwItemLayout
from novelwriter.common import (
checkInt, isHandle, isItemClass, isItemLayout, isItemType, simplified
@@ -151,39 +149,40 @@ class NWItem:
# Pack/Unpack Data
##
- def packXML(self, xParent):
- """Pack all the data in the class instance into an XML object.
+ def pack(self):
+ """Pack all the data in the class instance into a dictionary.
"""
- itemAttrib = {}
- itemAttrib["handle"] = str(self._handle)
- itemAttrib["parent"] = str(self._parent)
- itemAttrib["root"] = str(self._root)
- itemAttrib["order"] = str(self._order)
- itemAttrib["type"] = str(self._type.name)
- itemAttrib["class"] = str(self._class.name)
+ item = {}
+ meta = {}
+ name = {}
+
+ item["handle"] = str(self._handle)
+ item["parent"] = str(self._parent)
+ item["root"] = str(self._root)
+ item["order"] = str(self._order)
+ item["type"] = str(self._type.name)
+ item["class"] = str(self._class.name)
+ meta["expanded"] = str(self._expanded)
+ name["status"] = str(self._status)
+ name["import"] = str(self._import)
+
if self._type == nwItemType.FILE:
- itemAttrib["layout"] = str(self._layout.name)
+ item["layout"] = str(self._layout.name)
+ meta["heading"] = str(self._heading)
+ meta["charCount"] = str(self._charCount)
+ meta["wordCount"] = str(self._wordCount)
+ meta["paraCount"] = str(self._paraCount)
+ meta["cursorPos"] = str(self._cursorPos)
+ name["active"] = str(self._active)
- metaAttrib = {}
- metaAttrib["expanded"] = str(self._expanded)
- if self._type == nwItemType.FILE:
- metaAttrib["heading"] = str(self._heading)
- metaAttrib["charCount"] = str(self._charCount)
- metaAttrib["wordCount"] = str(self._wordCount)
- metaAttrib["paraCount"] = str(self._paraCount)
- metaAttrib["cursorPos"] = str(self._cursorPos)
+ data = {
+ "name": str(self._name),
+ "itemAttr": item,
+ "metaAttr": meta,
+ "nameAttr": name,
+ }
- nameAttrib = {}
- nameAttrib["status"] = str(self._status)
- nameAttrib["import"] = str(self._import)
- if self._type == nwItemType.FILE:
- nameAttrib["active"] = str(self._active)
-
- xPack = etree.SubElement(xParent, "item", attrib=itemAttrib)
- self._subPack(xPack, "meta", attrib=metaAttrib)
- self._subPack(xPack, "name", text=str(self._name), attrib=nameAttrib)
-
- return
+ return data
def unpack(self, data):
"""Set the values from a data dictionary.
@@ -201,7 +200,7 @@ class NWItem:
self.setClass(data.get("class", nwItemClass.NO_CLASS))
self.setLayout(data.get("layout", nwItemLayout.NO_LAYOUT))
self.setExpanded(data.get("expanded", False))
- self.setMainHeading(data.get("mainHeading", "H0"))
+ self.setMainHeading(data.get("heading", "H0"))
self.setCharCount(data.get("charCount", 0))
self.setWordCount(data.get("wordCount", 0))
self.setParaCount(data.get("paraCount", 0))
@@ -224,19 +223,6 @@ class NWItem:
return True
- @staticmethod
- def _subPack(xParent, name, attrib=None, text=None, none=True):
- """Pack the values into an XML element.
- """
- if not none and (text is None or text == "None"):
- return None
- xAttr = {} if attrib is None else attrib
- xSub = etree.SubElement(xParent, name, attrib=xAttr)
- if text is not None:
- xSub.text = text
-
- return
-
##
# Lookup Methods
##
diff --git a/novelwriter/core/project.py b/novelwriter/core/project.py
index df300ae4..c0230758 100644
--- a/novelwriter/core/project.py
+++ b/novelwriter/core/project.py
@@ -30,7 +30,6 @@ import logging
import novelwriter
from time import time
-from lxml import etree
from functools import partial
from PyQt5.QtCore import QCoreApplication
@@ -47,7 +46,7 @@ from novelwriter.core.item import NWItem
from novelwriter.core.index import NWIndex
from novelwriter.core.options import OptionState
from novelwriter.core.document import NWDoc
-from novelwriter.core.projectxml import ProjectXMLReader, XMLReadState
+from novelwriter.core.projectxml import ProjectXMLReader, ProjectXMLWriter, XMLReadState
from novelwriter.core.projectdata import NWProjectData
logger = logging.getLogger(__name__)
@@ -585,86 +584,16 @@ class NWProject:
else:
self._data.incSaveCount()
- # Root element and project details
- logger.debug("Writing project meta")
- nwXML = etree.Element("novelWriterXML", attrib={
- "appVersion": str(novelwriter.__version__),
- "hexVersion": str(novelwriter.__hexversion__),
- "fileVersion": self.FILE_VERSION,
- "timeStamp": formatTimeStamp(saveTime),
- })
-
self.updateWordCounts()
+ self.countStatus()
+
+ saveTime = time()
editTime = int(self._data.editTime + saveTime - self._projOpened)
- # Save Project Meta
- xProject = etree.SubElement(nwXML, "project")
- self._packProjectValue(xProject, "name", self._data.name)
- self._packProjectValue(xProject, "title", self._data.title)
- self._packProjectValue(xProject, "author", self._data.authors)
- self._packProjectValue(xProject, "saveCount", str(self._data.saveCount))
- self._packProjectValue(xProject, "autoCount", str(self._data.autoCount))
- self._packProjectValue(xProject, "editTime", str(editTime))
-
- # Save Project Settings
- xSettings = etree.SubElement(nwXML, "settings")
- self._packProjectValue(xSettings, "doBackup", self._data.doBackup)
- self._packProjectValue(xSettings, "language", self._data.language)
- self._packProjectValue(xSettings, "spellCheck", self._data.spellCheck)
- self._packProjectValue(xSettings, "spellLang", self._data.spellLang)
- self._packProjectValue(xSettings, "lastEdited", self._data.getLastHandle("editor"))
- self._packProjectValue(xSettings, "lastViewed", self._data.getLastHandle("viewer"))
- self._packProjectValue(xSettings, "lastNovel", self._data.getLastHandle("noveltree"))
- self._packProjectValue(xSettings, "lastOutline", self._data.getLastHandle("outline"))
- self._packProjectValue(xSettings, "lastWordCount", self._data.getCurrCount("total"))
- self._packProjectValue(xSettings, "novelWordCount", self._data.getCurrCount("novel"))
- self._packProjectValue(xSettings, "notesWordCount", self._data.getCurrCount("notes"))
- self._packProjectKeyValue(xSettings, "autoReplace", self._data.autoReplace)
-
- xTitleFmt = etree.SubElement(xSettings, "titleFormat")
- for aKey, aValue in self._data.titleFormat.items():
- if len(aKey) > 0:
- self._packProjectValue(xTitleFmt, aKey, aValue)
-
- # Save Status/Importance
- self.countStatus()
- xStatus = etree.SubElement(xSettings, "status")
- self._data.itemStatus.packXML(xStatus)
- xStatus = etree.SubElement(xSettings, "importance")
- self._data.itemImport.packXML(xStatus)
-
- # Save Tree Content
- logger.debug("Writing project content")
- self._projTree.packXML(nwXML)
-
- # Write the xml tree to file
- tempFile = os.path.join(self.projPath, nwFiles.PROJ_FILE+"~")
- saveFile = os.path.join(self.projPath, nwFiles.PROJ_FILE)
- backFile = os.path.join(self.projPath, nwFiles.PROJ_FILE[:-3]+"bak")
- try:
- with open(tempFile, mode="wb") as outFile:
- outFile.write(etree.tostring(
- nwXML,
- pretty_print=True,
- encoding="utf-8",
- xml_declaration=True
- ))
- except Exception as exc:
- self.mainGui.makeAlert(self.tr(
- "Failed to save project."
- ), nwAlert.ERROR, exception=exc)
- return False
-
- # If we're here, the file was successfully saved,
- # so let's sort out the temps and backups
- try:
- if os.path.isfile(saveFile):
- os.replace(saveFile, backFile)
- os.replace(tempFile, saveFile)
- except OSError as exc:
- self.mainGui.makeAlert(self.tr(
- "Failed to save project."
- ), nwAlert.ERROR, exception=exc)
+ content = self._projTree.pack()
+ xmlWriter = ProjectXMLWriter(self.projPath)
+ if not xmlWriter.write(self._data, content, saveTime, editTime):
+ self.mainGui.makeAlert(self.tr("Failed to save project."), nwAlert.ERROR)
return False
# Save project GUI options
@@ -1165,28 +1094,6 @@ class NWProject:
return False
return True
- def _packProjectValue(self, xParent, theName, theValue, allowNone=True):
- """Pack a list of values into an xml element.
- """
- if not isinstance(theValue, list):
- theValue = [theValue]
- for aValue in theValue:
- if (aValue == "" or aValue is None) and not allowNone:
- continue
- xItem = etree.SubElement(xParent, theName)
- xItem.text = str(aValue)
- return
-
- def _packProjectKeyValue(self, xParent, theName, theDict):
- """Pack the entries of a dictionary into an xml element.
- """
- xAutoRep = etree.SubElement(xParent, theName)
- for aKey, aValue in theDict.items():
- if len(aKey) > 0:
- xEntry = etree.SubElement(xAutoRep, "entry", attrib={"key": aKey})
- xEntry.text = aValue
- return
-
def _scanProjectFolder(self):
"""Scan the project folder and check that the files in it are
also in the project XML file. If they aren't, import them as
diff --git a/novelwriter/core/projectxml.py b/novelwriter/core/projectxml.py
index fe241efa..9b56a5d3 100644
--- a/novelwriter/core/projectxml.py
+++ b/novelwriter/core/projectxml.py
@@ -26,16 +26,19 @@ along with this program. If not, see .
import os
import logging
+import novelwriter
from enum import Enum
from lxml import etree
from novelwriter.common import (
- checkBool, checkInt, checkStringNone, simplified, checkString
+ checkBool, checkInt, checkStringNone, formatTimeStamp, simplified, checkString
)
+from novelwriter.constants import nwFiles
logger = logging.getLogger(__name__)
+FILE_VERSION = "1.4" # The current project file format version
NUM_VERSION = {
"1.0": 0x0100,
@@ -62,6 +65,29 @@ class XMLReadState(Enum):
class ProjectXMLReader:
+ """The main project XML file reader class. All data is read into a
+ NWProjectData instance, which must be provided.
+
+ Version Change History
+ ======================
+ 1.0 Original file format.
+
+ 1.1 Changes the way documents are structured in the project folder
+ from data_X, where X is the first hex value of the handle, to a
+ single content folder. Introduced in version 0.7.
+
+ 1.2 Changes the way autoReplace entries are stored. The 1.1 parser
+ will lose the autoReplace settings if allowed to read the file.
+ Introduced in version 0.10.
+
+ 1.3 Reduces the number of layouts to only two. One for novel
+ documents and one for project notes. Introduced in version 1.5.
+
+ 1.4 Introduces a more compact format for storing items. All settings
+ aside from name are now attributes. This format also changes the
+ way satus and importance labels are stored and handled.
+ Introduced in version 2.0.
+ """
def __init__(self, path):
@@ -86,30 +112,44 @@ class ProjectXMLReader:
@property
def content(self):
+ """The project content section, a dictionary of project items.
+ """
return self._content
@property
def state(self):
+ """The state of the parsing as an XMLReadState enum value.
+ """
return self._state
@property
def xmlRoot(self):
+ """The root tag name of the XNL file,
+ """
return self._root
@property
def xmlVersion(self):
+ """The project XML version number.
+ """
return self._version
@property
def appVersion(self):
+ """The novelWriter version number who wrote the file.
+ """
return self._appVersion
@property
def hexVersion(self):
+ """The novelWriter version number who wrote the file as hex.
+ """
return self._hexVersion
@property
def timeStamp(self):
+ """The date and time when the file was written.
+ """
return self._timeStamp
##
@@ -149,22 +189,6 @@ class ProjectXMLReader:
self._state = XMLReadState.NOT_NWX_FILE
return False
- # Changes:
- # 1.0 : Original file format.
- # 1.1 : Changes the way documents are structured in the project
- # folder from data_X, where X is the first hex value of
- # the handle, to a single content folder.
- # 1.2 : Changes the way autoReplace entries are stored. The 1.1
- # parser will lose the autoReplace settings if allowed to
- # read the file. Introduced in version 0.10.
- # 1.3 : Reduces the number of layouts to only two. One for novel
- # documents and one for project notes. Introduced in
- # version 1.5.
- # 1.4 : Introduces a more compact format for storing items. All
- # settings aside from name are now attributes. This format
- # also changes the way satus and importance labels are
- # stored and handled. Introduced in version 1.7.
-
fileVersion = str(xRoot.attrib.get("fileVersion", ""))
if fileVersion in NUM_VERSION:
self._version = NUM_VERSION[fileVersion]
@@ -425,11 +449,123 @@ class ProjectXMLWriter:
return
- def write(self):
- return
+ def write(self, projData, projContent, saveTime, editTime):
+
+ nwXML = etree.Element("novelWriterXML", attrib={
+ "appVersion": str(novelwriter.__version__),
+ "hexVersion": str(novelwriter.__hexversion__),
+ "fileVersion": FILE_VERSION,
+ "timeStamp": formatTimeStamp(saveTime),
+ })
+
+ # Save Project Meta
+ xProject = etree.SubElement(nwXML, "project")
+ self._packSingleValue(xProject, "name", projData.name)
+ self._packSingleValue(xProject, "title", projData.title)
+ self._packListValue(xProject, "author", projData.authors)
+ self._packSingleValue(xProject, "saveCount", projData.saveCount)
+ self._packSingleValue(xProject, "autoCount", projData.autoCount)
+ self._packSingleValue(xProject, "editTime", editTime)
+
+ # Save Project Settings
+ xSettings = etree.SubElement(nwXML, "settings")
+ self._packSingleValue(xSettings, "doBackup", projData.doBackup)
+ self._packSingleValue(xSettings, "language", projData.language)
+ self._packSingleValue(xSettings, "spellCheck", projData.spellCheck)
+ self._packSingleValue(xSettings, "spellLang", projData.spellLang)
+ self._packSingleValue(xSettings, "lastEdited", projData.getLastHandle("editor"))
+ self._packSingleValue(xSettings, "lastViewed", projData.getLastHandle("viewer"))
+ self._packSingleValue(xSettings, "lastNovel", projData.getLastHandle("noveltree"))
+ self._packSingleValue(xSettings, "lastOutline", projData.getLastHandle("outline"))
+ self._packSingleValue(xSettings, "lastWordCount", projData.getCurrCount("total"))
+ self._packSingleValue(xSettings, "novelWordCount", projData.getCurrCount("novel"))
+ self._packSingleValue(xSettings, "notesWordCount", projData.getCurrCount("notes"))
+ self._packDictKeyValue(xSettings, "autoReplace", projData.autoReplace)
+ self._packDictTagValue(xSettings, "titleFormat", projData.titleFormat)
+
+ # Save Status/Importance
+ xStatus = etree.SubElement(xSettings, "status")
+ for (label, attr) in projData.itemStatus.pack():
+ xEntry = etree.SubElement(xStatus, "entry", attrib=attr)
+ xEntry.text = label
+
+ xImport = etree.SubElement(xSettings, "importance")
+ for (label, attr) in projData.itemImport.pack():
+ xEntry = etree.SubElement(xImport, "entry", attrib=attr)
+ xEntry.text = label
+
+ # Save Tree Content
+ cAttr = {"count": str(len(projContent))}
+ xContent = etree.SubElement(nwXML, "content", attrib=cAttr)
+ for item in projContent:
+ xItem = etree.SubElement(xContent, "item", attrib=item.get("itemAttr", {}))
+ etree.SubElement(xItem, "meta", attrib=item.get("metaAttr", {}))
+ xName = etree.SubElement(xItem, "name", attrib=item.get("nameAttr", {}))
+ xName.text = item["name"]
+
+ # Write the xml tree to file
+ tempFile = os.path.join(self._path, nwFiles.PROJ_FILE+"~")
+ saveFile = os.path.join(self._path, nwFiles.PROJ_FILE)
+ backFile = os.path.join(self._path, nwFiles.PROJ_FILE[:-3]+"bak")
+ try:
+ with open(tempFile, mode="wb") as outFile:
+ outFile.write(etree.tostring(
+ nwXML,
+ pretty_print=True,
+ encoding="utf-8",
+ xml_declaration=True
+ ))
+ except Exception:
+ return False
+
+ # If we're here, the file was successfully saved,
+ # so let's sort out the temps and backups
+ try:
+ if os.path.isfile(saveFile):
+ os.replace(saveFile, backFile)
+ os.replace(tempFile, saveFile)
+ except OSError:
+ return False
+
+ return True
##
# Internal Functions
##
+ def _packSingleValue(self, xParent, name, value, allowNone=True):
+ """Pack a list of values into an xml element.
+ """
+ if (value == "" or value is None) and not allowNone:
+ return
+ xItem = etree.SubElement(xParent, name)
+ xItem.text = str(value)
+ return
+
+ def _packListValue(self, xParent, name, data, allowNone=True):
+ """Pack a list of values into an xml element.
+ """
+ for value in data:
+ self._packSingleValue(xParent, name, value, allowNone=allowNone)
+ return
+
+ def _packDictKeyValue(self, xParent, name, data):
+ """Pack the entries of a dictionary into an xml element.
+ """
+ xItem = etree.SubElement(xParent, name)
+ for key, value in data.items():
+ if len(key) > 0:
+ xEntry = etree.SubElement(xItem, "entry", attrib={"key": key})
+ xEntry.text = value
+ return
+
+ def _packDictTagValue(self, xParent, name, data):
+ """Pack the entries of a dictionary into an xml element.
+ """
+ xItem = etree.SubElement(xParent, name)
+ for aKey, value in data.items():
+ if len(aKey) > 0:
+ self._packSingleValue(xItem, aKey, value)
+ return
+
# END Class ProjectXMLWriter
diff --git a/novelwriter/core/status.py b/novelwriter/core/status.py
index 63268a4c..b02ed2ea 100644
--- a/novelwriter/core/status.py
+++ b/novelwriter/core/status.py
@@ -28,8 +28,6 @@ import random
import logging
import novelwriter
-from lxml import etree
-
from PyQt5.QtGui import QIcon, QPainter, QPainterPath, QPixmap, QColor
from PyQt5.QtCore import QRectF, Qt
@@ -204,21 +202,21 @@ class NWStatus:
self._store[key]["count"] += 1
return
- def packXML(self, xParent):
- """Pack the status entries into an XML object for saving to the
- main project file.
+ def pack(self):
+ """Pack the status entries into a dictionary.
"""
+ result = []
for key, data in self._store.items():
- xSub = etree.SubElement(xParent, "entry", attrib={
- "key": key,
- "count": str(data["count"]),
- "red": str(data["cols"][0]),
- "green": str(data["cols"][1]),
- "blue": str(data["cols"][2]),
- })
- xSub.text = data["name"]
-
- return True
+ result. append((
+ data["name"], {
+ "key": key,
+ "count": str(data["count"]),
+ "red": str(data["cols"][0]),
+ "green": str(data["cols"][1]),
+ "blue": str(data["cols"][2]),
+ }
+ ))
+ return result
def unpack(self, data):
"""Unpack a data dictionary and set the class values.
diff --git a/novelwriter/core/tree.py b/novelwriter/core/tree.py
index 1db11a27..e6d66f21 100644
--- a/novelwriter/core/tree.py
+++ b/novelwriter/core/tree.py
@@ -27,8 +27,6 @@ import os
import random
import logging
-from lxml import etree
-
from novelwriter.enum import nwItemClass, nwItemLayout
from novelwriter.error import logException
from novelwriter.common import checkHandle
@@ -114,17 +112,16 @@ class NWTree:
return True
- def packXML(self, xParent):
+ def pack(self):
"""Pack the content of the tree into the provided XML object. In
the order defined by the _treeOrder list.
"""
- xContent = etree.SubElement(xParent, "content", attrib={
- "count": str(len(self._treeOrder))}
- )
+ tree = []
for tHandle in self._treeOrder:
tItem = self.__getitem__(tHandle)
- tItem.packXML(xContent)
- return
+ if tItem:
+ tree.append(tItem.pack())
+ return tree
def unpack(self, data):
"""Iterate through all items of a list and add them to the
diff --git a/tests/reference/coreProject_NewFileFolder_nwProject.nwx b/tests/reference/coreProject_NewFileFolder_nwProject.nwx
index dd242336..8a390542 100644
--- a/tests/reference/coreProject_NewFileFolder_nwProject.nwx
+++ b/tests/reference/coreProject_NewFileFolder_nwProject.nwx
@@ -1,5 +1,5 @@
-
+
New Project
New Novel
@@ -59,7 +59,7 @@
World
-
-
+
Title Page
-
@@ -67,11 +67,11 @@
New Chapter
-
-
+
New Chapter
-
-
+
New Scene
-
diff --git a/tests/reference/coreProject_NewRoot_nwProject.nwx b/tests/reference/coreProject_NewRoot_nwProject.nwx
index 213106e6..1614eb63 100644
--- a/tests/reference/coreProject_NewRoot_nwProject.nwx
+++ b/tests/reference/coreProject_NewRoot_nwProject.nwx
@@ -1,5 +1,5 @@
-
+
New Project
New Novel
@@ -59,7 +59,7 @@
World
-
-
+
Title Page
-
@@ -67,11 +67,11 @@
New Chapter
-
-
+
New Chapter
-
-
+
New Scene
-
diff --git a/tests/test_core/test_core_project.py b/tests/test_core/test_core_project.py
index 8acf751c..6075dbe9 100644
--- a/tests/test_core/test_core_project.py
+++ b/tests/test_core/test_core_project.py
@@ -22,7 +22,6 @@ along with this program. If not, see .
import os
import pytest
-from lxml import etree
from shutil import copyfile
from zipfile import ZipFile
@@ -1033,31 +1032,6 @@ def testCoreProject_Methods(monkeypatch, mockGUI, tmpDir, fncDir, mockRnd):
"%s %s 200 100 99\n"
) % (formatTimeStamp(1600002000), formatTimeStamp(1600005600))
- # Pack XML Value
- xElem = etree.Element("element")
- theProject._packProjectValue(xElem, "A", "B", allowNone=False)
- assert etree.tostring(xElem, pretty_print=False, encoding="utf-8") == (
- b"B"
- )
-
- xElem = etree.Element("element")
- theProject._packProjectValue(xElem, "A", "", allowNone=False)
- assert etree.tostring(xElem, pretty_print=False, encoding="utf-8") == (
- b""
- )
-
- # Pack XML Key/Value
- xElem = etree.Element("element")
- theProject._packProjectKeyValue(xElem, "item", {"A": "B", "C": "D"})
- assert etree.tostring(xElem, pretty_print=False, encoding="utf-8") == (
- b""
- b"
- "
- b"B"
- b"D"
- b"
"
- b""
- )
-
# END Test testCoreProject_Methods