diff --git a/novelwriter/core/item.py b/novelwriter/core/item.py index eae4813a..5b7deabe 100644 --- a/novelwriter/core/item.py +++ b/novelwriter/core/item.py @@ -193,21 +193,22 @@ class NWItem: logger.error("XML item entry does not have a handle") return False + self.setName(data.get("label", "")) 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.setStatus(data.get("status", None)) + self.setImport(data.get("import", None)) self.setMainHeading(data.get("heading", "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 @@ -216,10 +217,12 @@ class NWItem: self._parent = None # Root items cannot have a parent if self._type != nwItemType.FILE: - self._charCount = 0 # Only set for files - self._wordCount = 0 # Only set for files - self._paraCount = 0 # Only set for files - self._cursorPos = 0 # Only set for files + self._heading = "H0" # Only files have headers + self._active = False # Can only be True for files + self._charCount = 0 # Only set for files + self._wordCount = 0 # Only set for files + self._paraCount = 0 # Only set for files + self._cursorPos = 0 # Only set for files return True diff --git a/novelwriter/core/project.py b/novelwriter/core/project.py index 9b1718c7..4f192955 100644 --- a/novelwriter/core/project.py +++ b/novelwriter/core/project.py @@ -455,13 +455,13 @@ class NWProject(QObject): # ========================= self._data = NWProjectData(self) - xmlReader = ProjectXMLReader(fileName) - xmlParsed = xmlReader.read(self._data) + projContent = [] + + xmlReader = ProjectXMLReader(fileName) + xmlParsed = xmlReader.read(self._data, projContent) - nwxRoot = xmlReader.xmlRoot appVersion = xmlReader.appVersion or self.tr("Unknown") hexVersion = xmlReader.hexVersion or "0x0" - xmlVersion = xmlReader.xmlVersion or self.tr("Unknown") if not xmlParsed: if xmlReader.state == XMLReadState.NOT_NWX_FILE: @@ -482,9 +482,6 @@ class NWProject(QObject): self.clearProject() return False - logger.debug("XML root is '%s'", nwxRoot) - logger.debug("File version is '%s'", xmlVersion) - # Check Legacy Upgrade # ==================== @@ -522,10 +519,7 @@ class NWProject(QObject): # Extract Data # ============ - logger.info("Project Name: '%s'", self._data.name) - logger.info("Project Title: '%s'", self._data.title) - - self._projTree.unpack(xmlReader.content) + self._projTree.unpack(projContent) self._optState.loadSettings() # Sort out old file locations diff --git a/novelwriter/core/projectxml.py b/novelwriter/core/projectxml.py index 66dfb294..69ed973a 100644 --- a/novelwriter/core/projectxml.py +++ b/novelwriter/core/projectxml.py @@ -30,6 +30,7 @@ import novelwriter from enum import Enum from lxml import etree +from time import time from novelwriter.common import ( checkBool, checkInt, checkStringNone, formatTimeStamp, simplified, checkString @@ -94,10 +95,6 @@ class ProjectXMLReader: self._path = path self._state = XMLReadState.NO_ACTION - self._content = [] - self._statusData = {} - self._statusMap = {} - self._root = "" self._version = 0x0000 self._appVersion = "" @@ -110,12 +107,6 @@ class ProjectXMLReader: # Properties ## - @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. @@ -156,10 +147,11 @@ class ProjectXMLReader: # Methods ## - def read(self, projData): + def read(self, projData, projContent): """Read and parse the project XML file. """ - self._content = [] + tStart = time() + logger.debug("Reading project XML") try: xml = etree.parse(self._path) @@ -196,6 +188,8 @@ class ProjectXMLReader: self._state = XMLReadState.UNKNOWN_VERSION return False + logger.debug("XML is '%s' version '%s'", self._root, fileVersion) + self._appVersion = str(xRoot.attrib.get("appVersion", "")) self._hexVersion = str(xRoot.attrib.get("appVersion", "")) self._timeStamp = str(xRoot.attrib.get("timeStamp", "")) @@ -208,10 +202,9 @@ class ProjectXMLReader: status &= self._parseProjectSettings(xSection, projData) elif xSection.tag == "content": if self._version >= 0x0104: - status &= self._parseProjectContent(xSection) + status &= self._parseProjectContent(xSection, projContent) else: - self._genLegacyImportStatysMap(projData) - status &= self._parseProjectContentLegacy(xSection) + status &= self._parseProjectContentLegacy(xSection, projContent, projData) else: logger.warning("Ignored in xml", xSection.tag) @@ -224,6 +217,8 @@ class ProjectXMLReader: else: self._state = XMLReadState.WAS_LEGACY + logger.debug("Project XML loaded in %.3f ms", (time() - tStart)*1000) + return True ## @@ -233,7 +228,7 @@ class ProjectXMLReader: def _parseProjectMeta(self, xSection, projData): """Parse the project section of the XML file. """ - logger.debug("Parsing xml ") + logger.debug("Parsing section") for xItem in xSection: if xItem.tag == "name": projData.setName(xItem.text) @@ -255,7 +250,7 @@ class ProjectXMLReader: def _parseProjectSettings(self, xSection, projData): """Parse the settings section of the XML file. """ - logger.debug("Parsing xml ") + logger.debug("Parsing section") for xItem in xSection: if xItem.tag == "doBackup": @@ -291,53 +286,56 @@ class ProjectXMLReader: return True - def _parseProjectContent(self, xSection): + def _parseProjectContent(self, xSection, projContent): """Parse the content section of the XML file. """ - logger.debug("Parsing xml ") + logger.debug("Parsing section") for xItem in xSection: if xItem.tag == "item": item = {} - item["handle"] = checkStringNone(xItem.attrib.get("handle", None), None) - item["parent"] = checkStringNone(xItem.attrib.get("parent", None), None) - item["root"] = checkStringNone(xItem.attrib.get("root", None), None) - item["order"] = checkInt(xItem.attrib.get("order", 0), 0) - item["type"] = checkString(xItem.attrib.get("type", "NO_TYPE"), "NO_TYPE") - item["class"] = checkString(xItem.attrib.get("class", "NO_CLASS"), "NO_CLASS") - item["layout"] = checkString(xItem.attrib.get("layout", "NO_LAYOUT"), "NO_LAYOUT") + item["handle"] = checkStringNone(xItem.attrib.get("handle"), None) + item["parent"] = checkStringNone(xItem.attrib.get("parent"), None) + item["root"] = checkStringNone(xItem.attrib.get("root"), None) + item["order"] = checkInt(xItem.attrib.get("order"), 0) + item["type"] = checkString(xItem.attrib.get("type"), "NO_TYPE") + item["class"] = checkString(xItem.attrib.get("class"), "NO_CLASS") + item["layout"] = checkString(xItem.attrib.get("layout"), "NO_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) + item["expanded"] = checkBool(xVal.attrib.get("expanded"), False) + item["heading"] = checkString(xVal.attrib.get("heading"), "H0") + item["charCount"] = checkInt(xVal.attrib.get("charCount"), 0) + item["wordCount"] = checkInt(xVal.attrib.get("wordCount"), 0) + item["paraCount"] = checkInt(xVal.attrib.get("paraCount"), 0) + item["cursorPos"] = checkInt(xVal.attrib.get("cursorPos"), 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) + item["status"] = checkStringNone(xVal.attrib.get("status"), None) + item["import"] = checkStringNone(xVal.attrib.get("import"), None) + item["active"] = checkBool(xVal.attrib.get("active"), 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) + item["active"] = checkBool(xVal.attrib.get("exported"), False) else: logger.warning("Ignored in xml", xVal.tag) - self._content.append(item) + projContent.append(item) else: logger.warning("Ignored item in xml", xItem.tag) return True - def _parseProjectContentLegacy(self, xSection): + def _parseProjectContentLegacy(self, xSection, projContent, projData): """Parse the content section of the XML file for older versions. """ - logger.debug("Parsing xml (legacy format)") - depLayout = ("TITLE", "PAGE", "BOOK", "PARTITION", "UNNUMBERED", "CHAPTER", "SCENE") + logger.debug("Parsing section (legacy format)") + + # Create maps to look up name -> key for status and importance + statusMap = {entry["name"]: key for key, entry in projData.itemStatus.items()} + importMap = {entry["name"]: key for key, entry in projData.itemImport.items()} for xItem in xSection: item = {} @@ -377,19 +375,21 @@ class ProjectXMLReader: # Status was split into separate status/import with a key in 1.4 if item.get("class", "") in ("NOVEL", "ARCHIVE"): - item["status"] = self._statusMap.get(tmpStatus, None) + item["status"] = statusMap.get(tmpStatus, None) else: - item["import"] = self._importMap.get(tmpStatus, None) + item["import"] = importMap.get(tmpStatus, None) # A number of layouts were removed in 1.3 - if item.get("layout", "") in depLayout: + if item.get("layout", "") in ( + "TITLE", "PAGE", "BOOK", "PARTITION", "UNNUMBERED", "CHAPTER", "SCENE" + ): item["layout"] = "DOCUMENT" # The trast type was removed in 1.4 if item.get("type", "") == "TRASH": item["type"] = "ROOT" - self._content.append(item) + projContent.append(item) else: logger.warning("Ignored in xml", xItem.tag) @@ -409,13 +409,6 @@ class ProjectXMLReader: sObject.write(key, xEntry.text, (red, green, blue), count) return - def _genLegacyImportStatysMap(self, projData): - """Generate a map of legacy import/status values. - """ - self._statusMap = {entry["name"]: key for key, entry in projData.itemStatus.items()} - self._importMap = {entry["name"]: key for key, entry in projData.itemImport.items()} - return - def _parseDictKeyText(self, xItem): """Parse a dictionary stored with key as an attribute and the value as the text porperty. @@ -459,8 +452,10 @@ class ProjectXMLWriter: def write(self, projData, projContent, saveTime, editTime): """Write the project data and content to the XML files. """ + tStart = time() + logger.debug("Writing project XML") - nwXML = etree.Element("novelWriterXML", attrib={ + xRoot = etree.Element("novelWriterXML", attrib={ "appVersion": str(novelwriter.__version__), "hexVersion": str(novelwriter.__hexversion__), "fileVersion": FILE_VERSION, @@ -468,7 +463,7 @@ class ProjectXMLWriter: }) # Save Project Meta - xProject = etree.SubElement(nwXML, "project") + xProject = etree.SubElement(xRoot, "project") self._packSingleValue(xProject, "name", projData.name) self._packSingleValue(xProject, "title", projData.title) self._packListValue(xProject, "author", projData.authors) @@ -477,7 +472,7 @@ class ProjectXMLWriter: self._packSingleValue(xProject, "editTime", editTime) # Save Project Settings - xSettings = etree.SubElement(nwXML, "settings") + xSettings = etree.SubElement(xRoot, "settings") self._packSingleValue(xSettings, "doBackup", projData.doBackup) self._packSingleValue(xSettings, "language", projData.language) self._packSingleValue(xSettings, "spellCheck", projData.spellCheck) @@ -498,7 +493,7 @@ class ProjectXMLWriter: self._packSingleValue(xImport, "entry", label, attrib=attrib) # Save Tree Content - xContent = etree.SubElement(nwXML, "content", attrib={"count": str(len(projContent))}) + xContent = etree.SubElement(xRoot, "content", attrib={"count": str(len(projContent))}) for item in projContent: xItem = etree.SubElement(xContent, "item", attrib=item.get("itemAttr", {})) etree.SubElement(xItem, "meta", attrib=item.get("metaAttr", {})) @@ -512,7 +507,7 @@ class ProjectXMLWriter: try: with open(tempFile, mode="wb") as outFile: outFile.write(etree.tostring( - nwXML, + xRoot, pretty_print=True, encoding="utf-8", xml_declaration=True @@ -531,6 +526,8 @@ class ProjectXMLWriter: self._error = exc return False + logger.debug("Project XML saved in %.3f ms", (time() - tStart)*1000) + return True ## diff --git a/novelwriter/core/status.py b/novelwriter/core/status.py index b02ed2ea..fea6397d 100644 --- a/novelwriter/core/status.py +++ b/novelwriter/core/status.py @@ -205,18 +205,15 @@ class NWStatus: def pack(self): """Pack the status entries into a dictionary. """ - result = [] for key, data in self._store.items(): - 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 + yield (data["name"], { + "key": key, + "count": str(data["count"]), + "red": str(data["cols"][0]), + "green": str(data["cols"][1]), + "blue": str(data["cols"][2]), + }) + return def unpack(self, data): """Unpack a data dictionary and set the class values. diff --git a/tests/test_base/test_base_common.py b/tests/test_base/test_base_common.py index 23b487e7..bb05265f 100644 --- a/tests/test_base/test_base_common.py +++ b/tests/test_base/test_base_common.py @@ -54,53 +54,63 @@ def testBaseCommon_CheckStringNone(): @pytest.mark.base def testBaseCommon_CheckString(): - """Test the checkString function. + """Test the checkString function. Anything that is a string should + be returned, otherwise it returns the default. """ - assert checkString("None", "NotNone") == "None" - assert checkString(None, "NotNone") == "NotNone" - assert checkString(1, "NotNone") == "NotNone" - assert checkString(1.0, "NotNone") == "NotNone" - assert checkString(True, "NotNone") == "NotNone" + assert checkString("None", "default") == "None" + assert checkString("Text", "default") == "Text" + assert checkString(None, "default") == "default" + assert checkString(1, "default") == "default" + assert checkString(1.0, "default") == "default" + assert checkString(True, "default") == "default" # END Test testBaseCommon_CheckString @pytest.mark.base def testBaseCommon_CheckInt(): - """Test the checkInt function. + """Test the checkInt function. Anything that can be converted to an + integer should be returned, otherwise it returns the default. """ - assert checkInt(None, 3) == 3 - assert checkInt("1", 3) == 1 - assert checkInt("1.0", 3) == 3 assert checkInt(1, 3) == 1 assert checkInt(1.0, 3) == 1 assert checkInt(True, 3) == 1 + assert checkInt(False, 3) == 0 + assert checkInt(None, 3) == 3 + assert checkInt("1", 3) == 1 + assert checkInt("1.0", 3) == 3 # END Test testBaseCommon_CheckInt @pytest.mark.base def testBaseCommon_CheckFloat(): - """Test the checkFloat function. + """Test the checkFloat function. Anything that can be converted to an + integer should be returned, otherwise it returns the default. """ - assert checkFloat(None, 3.0) == 3.0 - assert checkFloat("1", 3.0) == 1.0 - assert checkFloat("1.0", 3.0) == 1.0 assert checkFloat(1, 3.0) == 1.0 assert checkFloat(1.0, 3.0) == 1.0 assert checkFloat(True, 3.0) == 1.0 + assert checkFloat(False, 3.0) == 0.0 + assert checkFloat(None, 3.0) == 3.0 + assert checkFloat("1", 3.0) == 1.0 + assert checkFloat("1.0", 3.0) == 1.0 # END Test testBaseCommon_CheckInt @pytest.mark.base def testBaseCommon_CheckBool(): - """Test the checkBool function. + """Test the checkBool function. Any bool, string version of Python + bool, or integer 1 or 0, are returned as bool. Otherwise, the + default is returned. """ assert checkBool("True", False) is True assert checkBool("False", True) is False assert checkBool("Boo", False) is False assert checkBool("Boo", True) is True + assert checkBool(None, True) is True + assert checkBool(None, False) is False assert checkBool(0, True) is False assert checkBool(1, False) is True assert checkBool(2, True) is True