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