From ea80fd3c719ce6ec6b4eacc2d7f0bccdca1fbd26 Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Sun, 30 Oct 2022 22:51:31 +0100 Subject: [PATCH] Complete the XML parsing --- novelwriter/core/item.py | 107 ++++------- novelwriter/core/project.py | 211 +++++++-------------- novelwriter/core/projectxml.py | 331 ++++++++++++++++++++++++++++++++- novelwriter/core/status.py | 25 +-- novelwriter/core/tree.py | 14 +- 5 files changed, 447 insertions(+), 241 deletions(-) diff --git a/novelwriter/core/item.py b/novelwriter/core/item.py index 40426d67..66cb7fbe 100644 --- a/novelwriter/core/item.py +++ b/novelwriter/core/item.py @@ -148,7 +148,7 @@ class NWItem: return self._cursorPos ## - # XML Pack/Unpack + # Pack/Unpack Data ## def packXML(self, xParent): @@ -167,7 +167,7 @@ class NWItem: metaAttrib = {} metaAttrib["expanded"] = str(self._expanded) if self._type == nwItemType.FILE: - metaAttrib["mainHeading"] = str(self._heading) + metaAttrib["heading"] = str(self._heading) metaAttrib["charCount"] = str(self._charCount) metaAttrib["wordCount"] = str(self._wordCount) metaAttrib["paraCount"] = str(self._paraCount) @@ -185,70 +185,31 @@ class NWItem: return - def unpackXML(self, xItem): - """Set the values from an XML entry of type 'item'. + def unpack(self, data): + """Set the values from a data dictionary. """ - if xItem.tag != "item": - logger.error("XML entry is not an NWItem") - return False - - if "handle" in xItem.attrib: - self.setHandle(xItem.attrib["handle"]) + if "handle" in data: + self.setHandle(data["handle"]) else: logger.error("XML item entry does not have a handle") return False - self.setParent(xItem.attrib.get("parent", None)) - self.setRoot(xItem.attrib.get("root", None)) - self.setOrder(xItem.attrib.get("order", 0)) - self.setType(xItem.attrib.get("type", nwItemType.NO_TYPE)) - self.setClass(xItem.attrib.get("class", nwItemClass.NO_CLASS)) - self.setLayout(xItem.attrib.get("layout", nwItemLayout.NO_LAYOUT)) - - for xValue in xItem: - if xValue.tag == "meta": - self.setExpanded(xValue.attrib.get("expanded", False)) - self.setMainHeading(xValue.attrib.get("mainHeading", "H0")) - self.setCharCount(xValue.attrib.get("charCount", 0)) - self.setWordCount(xValue.attrib.get("wordCount", 0)) - self.setParaCount(xValue.attrib.get("paraCount", 0)) - self.setCursorPos(xValue.attrib.get("cursorPos", 0)) - elif xValue.tag == "name": - self.setName(xValue.text) - self.setStatus(xValue.attrib.get("status", None)) - self.setImport(xValue.attrib.get("import", None)) - self.setActive(xValue.attrib.get("active", True)) - - # ToDo: Remove before 2.0 release. Only needed for 2.0 pre-releases. - if "exported" in xValue.attrib: - self.setActive(xValue.attrib.get("exported", True)) - - # Legacy Format (1.3 and earlier) - elif xValue.tag == "status": - self.setImportStatus(xValue.text) - elif xValue.tag == "type": - self.setType(xValue.text) - elif xValue.tag == "class": - self.setClass(xValue.text) - elif xValue.tag == "layout": - self.setLayout(xValue.text) - elif xValue.tag == "expanded": - self.setExpanded(xValue.text) - elif xValue.tag == "exported": - self.setActive(xValue.text) - elif xValue.tag == "charCount": - self.setCharCount(xValue.text) - elif xValue.tag == "wordCount": - self.setWordCount(xValue.text) - elif xValue.tag == "paraCount": - self.setParaCount(xValue.text) - elif xValue.tag == "cursorPos": - self.setCursorPos(xValue.text) - else: - # Sliently skip as we may otherwise cause orphaned - # items if an otherwise valid file is opened by a - # version of novelWriter that doesn't know the tag - logger.error("Unknown tag '%s'", xValue.tag) + self.setParent(data.get("parent", None)) + self.setRoot(data.get("root", None)) + self.setOrder(data.get("order", 0)) + self.setType(data.get("type", nwItemType.NO_TYPE)) + 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.setCharCount(data.get("charCount", 0)) + self.setWordCount(data.get("wordCount", 0)) + self.setParaCount(data.get("paraCount", 0)) + self.setCursorPos(data.get("cursorPos", 0)) + self.setName(data.get("label", "")) + self.setStatus(data.get("status", None)) + self.setImport(data.get("import", None)) + self.setActive(data.get("active", True)) # Make some checks to ensure consistency if self._type == nwItemType.ROOT: @@ -451,8 +412,6 @@ class NWItem: self._type = value elif isItemType(value): self._type = nwItemType[value] - elif value == "TRASH": - self._type = nwItemType.ROOT else: logger.error("Unrecognised item type '%s'", value) self._type = nwItemType.NO_TYPE @@ -479,8 +438,6 @@ class NWItem: self._layout = value elif isItemLayout(value): self._layout = nwItemLayout[value] - elif value in ("TITLE", "PAGE", "BOOK", "PARTITION", "UNNUMBERED", "CHAPTER", "SCENE"): - self._layout = nwItemLayout.DOCUMENT else: logger.error("Unrecognised item layout '%s'", value) self._layout = nwItemLayout.NO_LAYOUT @@ -532,25 +489,37 @@ class NWItem: def setCharCount(self, count): """Set the character count, and ensure that it is an integer. """ - self._charCount = max(0, checkInt(count, 0)) + if isinstance(count, int): + self._charCount = max(0, count) + else: + self._charCount = 0 return def setWordCount(self, count): """Set the word count, and ensure that it is an integer. """ - self._wordCount = max(0, checkInt(count, 0)) + if isinstance(count, int): + self._wordCount = max(0, count) + else: + self._wordCount = 0 return def setParaCount(self, count): """Set the paragraph count, and ensure that it is an integer. """ - self._paraCount = max(0, checkInt(count, 0)) + if isinstance(count, int): + self._paraCount = max(0, count) + else: + self._paraCount = 0 return def setCursorPos(self, position): """Set the cursor position, and ensure that it is an integer. """ - self._cursorPos = max(0, checkInt(position, 0)) + if isinstance(position, int): + self._cursorPos = max(0, position) + else: + self._cursorPos = 0 return def saveInitialCount(self): diff --git a/novelwriter/core/project.py b/novelwriter/core/project.py index 66f9617b..76294a08 100644 --- a/novelwriter/core/project.py +++ b/novelwriter/core/project.py @@ -35,18 +35,19 @@ from functools import partial from PyQt5.QtCore import QCoreApplication +from novelwriter.enum import nwItemType, nwItemClass, nwItemLayout, nwAlert +from novelwriter.error import logException +from novelwriter.common import ( + checkString, checkStringNone, isHandle, formatTimeStamp, + makeFileNameSafe, hexToInt, minmax, simplified +) from novelwriter.core.tree import NWTree from novelwriter.core.item import NWItem from novelwriter.core.index import NWIndex from novelwriter.core.status import NWStatus from novelwriter.core.options import OptionState from novelwriter.core.document import NWDoc -from novelwriter.enum import nwItemType, nwItemClass, nwItemLayout, nwAlert -from novelwriter.error import logException -from novelwriter.common import ( - checkString, checkBool, checkInt, checkStringNone, isHandle, formatTimeStamp, - makeFileNameSafe, hexToInt, minmax, simplified -) +from novelwriter.core.projectxml import ProjectXMLReader, XMLReadState from novelwriter.constants import trConst, nwFiles, nwLabels logger = logging.getLogger(__name__) @@ -62,6 +63,8 @@ class NWProject: self.mainConf = novelwriter.CONFIG self.mainGui = mainGui + self._data = {} + # Core Elements self._optState = OptionState(self) # Project-specific GUI options self._projTree = NWTree(self) # The project tree @@ -477,80 +480,45 @@ class NWProject: # Open The Project XML File # ========================= - try: - nwXML = etree.parse(fileName) - except Exception as exc: - self.mainGui.makeAlert(self.tr( - "Failed to parse project xml." - ), nwAlert.ERROR, exception=exc) + xmlReader = ProjectXMLReader(fileName) + xmlParsed = xmlReader.read() + xmlData = xmlReader.data - # Trying to open backup file instead - backFile = fileName[:-3]+"bak" - if os.path.isfile(backFile): + print(json.dumps(xmlData, indent=2)) + + nwxRoot = xmlData.get("xmlRoot", "") + appVersion = xmlData.get("appVersion", self.tr("Unknown")) + hexVersion = xmlData.get("hexVersion", 0x0000) + xmlVersion = xmlData.get("xmlVersion", self.tr("Unknown")) + + if not xmlParsed: + if xmlReader.state == XMLReadState.NOT_NWX_FILE: self.mainGui.makeAlert(self.tr( - "Attempting to open backup project file instead." - ), nwAlert.INFO) - try: - nwXML = etree.parse(backFile) - except Exception as exc: - self.mainGui.makeAlert(self.tr( - "Failed to parse project xml." - ), nwAlert.ERROR, exception=exc) - self.clearProject() - return False + "Project file does not appear to be a novelWriterXML file." + ), nwAlert.ERROR) + elif xmlReader.state == XMLReadState.UNKNOWN_VERSION: + self.mainGui.makeAlert(self.tr( + "Unknown or unsupported novelWriter project file format. " + "The project cannot be opened by this version of novelWriter. " + "The file was saved with novelWriter version {0}." + ).format(appVersion), nwAlert.ERROR) else: - self.clearProject() - return False + self.mainGui.makeAlert(self.tr( + "Failed to parse project xml." + ), nwAlert.ERROR) - xRoot = nwXML.getroot() - nwxRoot = xRoot.tag + self.clearProject() + return False - appVersion = xRoot.attrib.get("appVersion", self.tr("Unknown")) - hexVersion = xRoot.attrib.get("hexVersion", "0x0") - fileVersion = xRoot.attrib.get("fileVersion", self.tr("Unknown")) + self._data = xmlData logger.debug("XML root is '%s'", nwxRoot) - logger.debug("File version is '%s'", fileVersion) + logger.debug("File version is '%s'", xmlVersion) - # Check File Type - # =============== + # Check Legacy Upgrade + # ==================== - if nwxRoot != "novelWriterXML": - self.mainGui.makeAlert(self.tr( - "Project file does not appear to be a novelWriterXML file." - ), nwAlert.ERROR) - self.clearProject() - return False - - # Check Project Storage Version - # ============================= - - # 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. - - if fileVersion not in ("1.0", "1.1", "1.2", "1.3", "1.4"): - self.mainGui.makeAlert(self.tr( - "Unknown or unsupported novelWriter project file format. " - "The project cannot be opened by this version of novelWriter. " - "The file was saved with novelWriter version {0}." - ).format(appVersion), nwAlert.ERROR) - self.clearProject() - return False - - if fileVersion != self.FILE_VERSION: + if xmlReader.state == XMLReadState.WAS_LEGACY: msgYes = self.mainGui.askQuestion( self.tr("File Version"), self.tr( @@ -581,79 +549,40 @@ class NWProject: self.clearProject() return False - # Start Parsing the XML - # ===================== + # Extract Data + # ============ - for xChild in xRoot: - if xChild.tag == "project": - logger.debug("Found project meta") - for xItem in xChild: - if xItem.text is None: - continue - if xItem.tag == "name": - self.projName = simplified(checkString(xItem.text, "")) - logger.info("Project Name: '%s'", self.projName) - elif xItem.tag == "title": - self.bookTitle = simplified(checkString(xItem.text, "")) - logger.info("Project Title: '%s'", self.bookTitle) - elif xItem.tag == "author": - author = simplified(checkString(xItem.text, "")) - if author: - self.bookAuthors.append(author) - logger.debug("Author: '%s'", author) - elif xItem.tag == "saveCount": - self.saveCount = checkInt(xItem.text, 0) - elif xItem.tag == "autoCount": - self.autoCount = checkInt(xItem.text, 0) - elif xItem.tag == "editTime": - self.editTime = checkInt(xItem.text, 0) + xmlProject = xmlData.get("project", {}) - elif xChild.tag == "settings": - logger.debug("Found project settings") - for xItem in xChild: - if xItem.text is None: - continue - if xItem.tag == "doBackup": - self.doBackup = checkBool(xItem.text, False) - elif xItem.tag == "language": - self.projLang = checkStringNone(xItem.text, None) - elif xItem.tag == "spellCheck": - self.spellCheck = checkBool(xItem.text, False) - elif xItem.tag == "spellLang": - self.projSpell = checkStringNone(xItem.text, None) - elif xItem.tag == "lastEdited": - self.lastEdited = checkStringNone(xItem.text, None) - elif xItem.tag == "lastViewed": - self.lastViewed = checkStringNone(xItem.text, None) - elif xItem.tag == "lastNovel": - self.lastNovel = checkStringNone(xItem.text, None) - elif xItem.tag == "lastOutline": - self.lastOutline = checkStringNone(xItem.text, None) - elif xItem.tag == "lastWordCount": - self.lastWCount = checkInt(xItem.text, 0) - elif xItem.tag == "novelWordCount": - self.lastNovelWC = checkInt(xItem.text, 0) - elif xItem.tag == "notesWordCount": - self.lastNotesWC = checkInt(xItem.text, 0) - elif xItem.tag == "status": - self.statusItems.unpackXML(xItem) - elif xItem.tag == "importance": - self.importItems.unpackXML(xItem) - elif xItem.tag == "autoReplace": - for xEntry in xItem: - if xEntry.tag == "entry" and "key" in xEntry.attrib: - self.autoReplace[xEntry.attrib["key"]] = checkString( - xEntry.text, "ERROR" - ) - elif xItem.tag == "titleFormat": - titleFormat = self.titleFormat.copy() - for xEntry in xItem: - titleFormat[xEntry.tag] = checkString(xEntry.text, "") - self.setTitleFormat(titleFormat) + self.projName = xmlProject.get("name", "") + self.bookTitle = xmlProject.get("title", "") + self.bookAuthors = xmlProject.get("authors", []) + self.saveCount = xmlProject.get("saveCount", 0) + self.autoCount = xmlProject.get("autoCount", 0) + self.editTime = xmlProject.get("editTime", 0) - elif xChild.tag == "content": - logger.debug("Found project content") - self._projTree.unpackXML(xChild) + logger.info("Project Name: '%s'", self.projName) + logger.info("Project Title: '%s'", self.bookTitle) + + xmlSettings = xmlData.get("settings", {}) + + self.doBackup = xmlSettings.get("doBackup", False) + self.projLang = xmlSettings.get("language", None) + self.spellCheck = xmlSettings.get("spellCheck", False) + self.projSpell = xmlSettings.get("spellLang", None) + self.lastEdited = xmlSettings.get("lastEdited", None) + self.lastViewed = xmlSettings.get("lastViewed", None) + self.lastNovel = xmlSettings.get("lastNovel", None) + self.lastOutline = xmlSettings.get("lastOutline", None) + self.lastWCount = xmlSettings.get("lastWordCount", 0) + self.lastNovelWC = xmlSettings.get("novelWordCount", 0) + self.lastNotesWC = xmlSettings.get("notesWordCount", 0) + self.statusItems.unpack(xmlSettings.get("status", {})) + self.importItems.unpack(xmlSettings.get("import", {})) + self.autoReplace = xmlSettings.get("autoReplace", {}) + self.titleFormat.update(xmlSettings.get("titleFormat", {})) + + self._projTree.unpack(xmlData.get("content", [])) self._optState.loadSettings() diff --git a/novelwriter/core/projectxml.py b/novelwriter/core/projectxml.py index b47a6181..2aa2d248 100644 --- a/novelwriter/core/projectxml.py +++ b/novelwriter/core/projectxml.py @@ -29,15 +29,33 @@ import logging from enum import Enum from lxml import etree +from novelwriter.common import ( + checkBool, checkInt, checkStringNone, minmax, simplified, checkString +) + logger = logging.getLogger(__name__) +NUM_VERSION = { + "1.0": 0x0100, + "1.1": 0x0101, + "1.2": 0x0102, + "1.3": 0x0103, + "1.4": 0x0104, +} + + class XMLReadState(Enum): - NO_ACTION = 0 - NO_ERROR = 1 - PARSED_BACKUP = 2 - CANNOT_PARSE = 3 + NO_ACTION = 0 + NO_ERROR = 1 + PARSED_BACKUP = 2 + CANNOT_PARSE = 3 + NOT_NWX_FILE = 4 + UNKNOWN_VERSION = 5 + PARSING_ERROR = 6 + PARSED_OK = 7 + WAS_LEGACY = 8 # END Class XMLReadState @@ -49,11 +67,34 @@ class ProjectXMLReader: self._path = path self._state = XMLReadState.NO_ACTION + self._data = {} + self._version = 0x0000 + self._statusData = {} + self._statusMap = {} + return + ## + # Properties + ## + + @property + def data(self): + return self._data + + @property + def state(self): + return self._state + + ## + # Methods + ## + def read(self): + """Read and parse the project XML file. """ - """ + self._data = {} + try: xml = etree.parse(self._path) self._state = XMLReadState.NO_ERROR @@ -76,12 +117,292 @@ class ProjectXMLReader: else: return False + xRoot = xml.getroot() + self._data["xmlRoot"] = str(xRoot.tag) + if xRoot.tag != "novelWriterXML": + 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] + else: + self._state = XMLReadState.UNKNOWN_VERSION + return False + + self._data["xmlVersion"] = self._version + self._data["appVersion"] = str(xRoot.attrib.get("appVersion", "")) + self._data["hexVersion"] = str(xRoot.attrib.get("appVersion", "")) + self._data["timeStamp"] = str(xRoot.attrib.get("timeStamp", "")) + + status = True + for xSection in xRoot: + if xSection.tag == "project": + status &= self._parseProjectMeta(xSection) + elif xSection.tag == "settings": + status &= self._parseProjectSettings(xSection) + elif xSection.tag == "content": + if self._version >= 0x0104: + status &= self._parseProjectContent(xSection) + else: + status &= self._parseProjectContentLegacy(xSection) + else: + logger.warning("Ignored in xml", xSection.tag) + + if not status: + self._state = XMLReadState.PARSING_ERROR + return False + + if self._version == 0x0104: + self._state = XMLReadState.PARSED_OK + else: + self._state = XMLReadState.WAS_LEGACY + return True ## # Internal Functions ## + def _parseProjectMeta(self, xSection): + """Parse the project section of the XML file. + """ + logger.debug("Parsing xml ") + data = {} + authors = [] + for xItem in xSection: + if xItem.tag == "name": + data["name"] = simplified(checkString(xItem.text, "")) + elif xItem.tag == "title": + data["title"] = simplified(checkString(xItem.text, "")) + elif xItem.tag == "author": + authors.append(simplified(checkString(xItem.text, ""))) + elif xItem.tag == "saveCount": + data["saveCount"] = checkInt(xItem.text, 0) + elif xItem.tag == "autoCount": + data["autoCount"] = checkInt(xItem.text, 0) + elif xItem.tag == "editTime": + data["editTime"] = checkInt(xItem.text, 0) + else: + logger.warning("Ignored in xml", xItem.tag) + + data["authors"] = authors + self._data["project"] = data + + return True + + def _parseProjectSettings(self, xSection): + """Parse the settings section of the XML file. + """ + logger.debug("Parsing xml ") + + data = {} + autoReplace = {} + titleFormat = {} + for xItem in xSection: + if xItem.tag == "doBackup": + data["doBackup"] = checkBool(xItem.text, False) + elif xItem.tag == "language": + data["language"] = checkStringNone(xItem.text, None) + elif xItem.tag == "spellCheck": + data["spellCheck"] = checkBool(xItem.text, False) + elif xItem.tag == "spellLang": + data["spellLang"] = checkStringNone(xItem.text, None) + elif xItem.tag == "lastEdited": + data["lastEdited"] = checkStringNone(xItem.text, None) + elif xItem.tag == "lastViewed": + data["lastViewed"] = checkStringNone(xItem.text, None) + elif xItem.tag == "lastNovel": + data["lastNovel"] = checkStringNone(xItem.text, None) + elif xItem.tag == "lastOutline": + data["lastOutline"] = checkStringNone(xItem.text, None) + elif xItem.tag == "lastWordCount": + data["lastWordCount"] = checkInt(xItem.text, 0) + elif xItem.tag == "novelWordCount": + data["novelWordCount"] = checkInt(xItem.text, 0) + elif xItem.tag == "notesWordCount": + data["notesWordCount"] = checkInt(xItem.text, 0) + elif xItem.tag == "status": + data["status"] = self._parseStatusImport(xItem, "status") + elif xItem.tag in ("import", "importance"): + data["import"] = self._parseStatusImport(xItem, "import") + elif xItem.tag == "autoReplace": + if self._version >= 0x0102: + for xEntry in xItem: + if xEntry.tag == "entry" and "key" in xEntry.attrib: + autoReplace[xEntry.attrib["key"]] = checkString(xEntry.text, "ERROR") + else: # Pre 1.2 format + for xEntry in xItem: + autoReplace[xEntry.tag] = checkString(xEntry.text, "ERROR") + elif xItem.tag == "titleFormat": + for xEntry in xItem: + titleFormat[xEntry.tag] = checkString(xEntry.text, "") + else: + logger.warning("Ignored in xml", xItem.tag) + + data["autoReplace"] = autoReplace + data["titleFormat"] = titleFormat + self._data["settings"] = data + + return True + + def _parseProjectContent(self, xSection): + """Parse the content section of the XML file. + """ + logger.debug("Parsing xml ") + + data = [] + for xItem in xSection: + if xItem.tag == "item": + item = {} + item["handle"] = xItem.attrib.get("handle", None) + item["parent"] = xItem.attrib.get("parent", None) + item["root"] = xItem.attrib.get("root", None) + item["order"] = checkInt(xItem.attrib.get("order", 0), 0) + item["type"] = checkString(xItem.attrib.get("type", ""), "") + item["class"] = checkString(xItem.attrib.get("class", ""), "") + item["layout"] = checkString(xItem.attrib.get("layout", ""), "") + for xVal in xItem: + if xVal.tag == "meta": + item["expanded"] = checkBool(xVal.attrib.get("expanded", False), False) + item["heading"] = checkString(xVal.attrib.get("heading", "H0"), "H0") + item["charCount"] = checkInt(xVal.attrib.get("charCount", 0), 0) + item["wordCount"] = checkInt(xVal.attrib.get("wordCount", 0), 0) + item["paraCount"] = checkInt(xVal.attrib.get("paraCount", 0), 0) + item["cursorPos"] = checkInt(xVal.attrib.get("cursorPos", 0), 0) + elif xVal.tag == "name": + item["label"] = simplified(checkString(xVal.text, "")) + item["status"] = checkStringNone(xVal.attrib.get("status", None), None) + item["import"] = checkStringNone(xVal.attrib.get("import", None), None) + item["active"] = checkBool(xVal.attrib.get("active", False), False) + + # ToDo: Remove before 2.0 release. Only needed for 2.0 pre-releases. + if "exported" in xVal.attrib: + item["active"] = checkBool(xVal.attrib.get("exported", False), False) + else: + logger.warning("Ignored in xml", xVal.tag) + data.append(item) + else: + logger.warning("Ignored item in xml", xItem.tag) + + self._data["content"] = data + + return True + + def _parseProjectContentLegacy(self, xSection): + """Parse the content section of the XML file for version before 1.4. + """ + logger.debug("Parsing xml (legacy format)") + depLayout = ("TITLE", "PAGE", "BOOK", "PARTITION", "UNNUMBERED", "CHAPTER", "SCENE") + data = [] + for xItem in xSection: + item = {} + if xItem.tag == "item": + item["handle"] = xItem.attrib.get("handle", None) + item["parent"] = xItem.attrib.get("parent", None) + item["root"] = None # Value was added in 1.4 + item["order"] = checkInt(xItem.attrib.get("order", 0), 0) + item["heading"] = "H0" # Value was added in 1.4 + + tmpStatus = "" + for xVal in xItem: + if xVal.tag == "name": + item["label"] = simplified(checkString(xVal.text, "")) + elif xVal.tag == "status": + tmpStatus = checkStringNone(xVal.text, None) + elif xVal.tag == "type": + item["type"] = checkString(xVal.text, "") + elif xVal.tag == "class": + item["class"] = checkString(xVal.text, "") + elif xVal.tag == "layout": + item["layout"] = checkString(xVal.text, "") + elif xVal.tag == "expanded": + item["expanded"] = checkBool(xVal.text, False) + elif xVal.tag == "exported": # Renamed to active in 1.4 + item["active"] = checkBool(xVal.text, False) + elif xVal.tag == "charCount": + item["charCount"] = checkInt(xVal.text, 0) + elif xVal.tag == "wordCount": + item["wordCount"] = checkInt(xVal.text, 0) + elif xVal.tag == "paraCount": + item["paraCount"] = checkInt(xVal.text, 0) + elif xVal.tag == "cursorPos": + item["cursorPos"] = checkInt(xVal.text, 0) + else: + logger.warning("Ignored in xml", xVal.tag) + + # Status was split into separate status/import with a key in 1.4 + if item.get("class", "") in ("NOVEL", "ARCHIVE"): + item["status"] = self._getLegacyUnportStatus(tmpStatus, "status") + else: + item["import"] = self._getLegacyUnportStatus(tmpStatus, "import") + + # A number of layouts were removed in 1.3 + if item.get("layout", "") in depLayout: + item["layout"] = "DOCUMENT" + + # The trast type was removed in 1.4 + if item.get("type", "") == "TRASH": + item["type"] = "ROOT" + + data.append(item) + else: + logger.warning("Ignored in xml", xItem.tag) + + self._data["content"] = data + + return True + + def _parseStatusImport(self, xItem, type): + """Parse a status or importance entry. + """ + data = self._statusData.get(type, {}) + for xEntry in xItem: + if xEntry.tag == "entry": + key = xEntry.attrib.get("key", f"{type[0]}{len(data):06x}") + data[key] = { + "label": xEntry.text, + "count": checkInt(xEntry.attrib.get("count", 0), 0), + "colour": ( + minmax(checkInt(xEntry.attrib.get("red", 0), 0), 0, 255), + minmax(checkInt(xEntry.attrib.get("green", 0), 0), 0, 255), + minmax(checkInt(xEntry.attrib.get("blue", 0), 0), 0, 255), + ), + } + self._statusData[type] = data + + return data + + def _getLegacyUnportStatus(self, label, type): + """Look up the label in defined status or importance values. + This is needed for file formats prior to 1.4 where the status + was saved as the label, not the key. + """ + if not self._statusMap.get(type): + lookup = {} + for key, entry in self._statusData.get(type, {}).items(): + lookup[entry.get("label", "")] = key + self._statusMap[type] = lookup + print(lookup) + + return self._statusMap.get(type, {}).get(label, None) + # END Class ProjectXMLReader diff --git a/novelwriter/core/status.py b/novelwriter/core/status.py index c466461e..1d19a08e 100644 --- a/novelwriter/core/status.py +++ b/novelwriter/core/status.py @@ -33,7 +33,7 @@ from lxml import etree from PyQt5.QtGui import QIcon, QPainter, QPainterPath, QPixmap, QColor from PyQt5.QtCore import QRectF, Qt -from novelwriter.common import checkInt, minmax, simplified +from novelwriter.common import simplified logger = logging.getLogger(__name__) @@ -47,7 +47,6 @@ class NWStatus: self._type = type self._store = {} - self._reverse = {} self._default = None self._iPX = novelwriter.CONFIG.pxInt(24) @@ -90,7 +89,6 @@ class NWStatus: "cols": col, "count": count, } - self._reverse[name] = key if self._default is None: self._default = key @@ -106,7 +104,6 @@ class NWStatus: if self._store[key]["count"] > 0: return False - del self._reverse[self._store[key]["name"]] del self._store[key] keys = list(self._store.keys()) @@ -123,8 +120,6 @@ class NWStatus: """ if self._isKey(value) and value in self._store: return value - elif value in self._reverse: - return self._reverse[value] elif self._default is not None: return self._default else: @@ -222,21 +217,17 @@ class NWStatus: return True - def unpackXML(self, xParent): - """Unpack an XML tree and set the class values. + def unpack(self, data): + """Unpack a data dictionary and set the class values. """ self._store = {} - self._reverse = {} self._default = None - for xChild in xParent: - key = xChild.attrib.get("key", None) - name = xChild.text.strip() - count = max(checkInt(xChild.attrib.get("count", 0), 0), 0) - red = minmax(checkInt(xChild.attrib.get("red", 100), 100), 0, 255) - green = minmax(checkInt(xChild.attrib.get("green", 100), 100), 0, 255) - blue = minmax(checkInt(xChild.attrib.get("blue", 100), 100), 0, 255) - self.write(key, name, (red, green, blue), count) + for key, entry in data.items(): + label = entry.get("label", "") + colour = entry.get("colour", (100, 100, 100)) + count = entry.get("count", 0) + self.write(key, label, colour, count) return True diff --git a/novelwriter/core/tree.py b/novelwriter/core/tree.py index 506556cd..1db11a27 100644 --- a/novelwriter/core/tree.py +++ b/novelwriter/core/tree.py @@ -126,18 +126,14 @@ class NWTree: tItem.packXML(xContent) return - def unpackXML(self, xContent): - """Iterate through all items of a content XML object and add - them to the project tree. + def unpack(self, data): + """Iterate through all items of a list and add them to the + project tree. """ - if xContent.tag != "content": - logger.error("XML entry is not a NWTree") - return False - self.clear() - for xItem in xContent: + for item in data: nwItem = NWItem(self.theProject) - if nwItem.unpackXML(xItem): + if nwItem.unpack(item): self.append(nwItem.itemHandle, nwItem.itemParent, nwItem) nwItem.saveInitialCount()