diff --git a/novelwriter/core/index.py b/novelwriter/core/index.py index 1058628b..ce1a1278 100644 --- a/novelwriter/core/index.py +++ b/novelwriter/core/index.py @@ -75,7 +75,7 @@ class NWIndex: return def __repr__(self): - return f"" + return f"" ## # Properties diff --git a/novelwriter/core/item.py b/novelwriter/core/item.py index 40426d67..8a45fa35 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 @@ -148,107 +146,70 @@ class NWItem: return self._cursorPos ## - # XML Pack/Unpack + # 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["mainHeading"] = 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) + return data - xPack = etree.SubElement(xParent, "item", attrib=itemAttrib) - self._subPack(xPack, "meta", attrib=metaAttrib) - self._subPack(xPack, "name", text=str(self._name), attrib=nameAttrib) - - 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") + logger.error("Item 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)) + 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)) - 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.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.setActive(data.get("active", True)) # Make some checks to ensure consistency if self._type == nwItemType.ROOT: @@ -256,26 +217,17 @@ 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 + # Reset values that should only be set for files + self._layout = nwItemLayout.NO_LAYOUT + self._heading = "H0" + self._active = False + self._charCount = 0 + self._wordCount = 0 + self._paraCount = 0 + self._cursorPos = 0 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 ## @@ -310,11 +262,11 @@ class NWItem: the current item based on its class. """ if self.isNovelLike(): - stName = self.theProject.statusItems.name(self._status) - stIcon = self.theProject.statusItems.icon(self._status) if incIcon else None + stName = self.theProject.data.itemStatus.name(self._status) + stIcon = self.theProject.data.itemStatus.icon(self._status) if incIcon else None else: - stName = self.theProject.importItems.name(self._import) - stIcon = self.theProject.importItems.icon(self._import) if incIcon else None + stName = self.theProject.data.itemImport.name(self._import) + stIcon = self.theProject.data.itemImport.icon(self._import) if incIcon else None return stName, stIcon ## @@ -451,8 +403,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 +429,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 @@ -490,14 +438,14 @@ class NWItem: """Set the item status by looking it up in the valid status items of the current project. """ - self._status = self.theProject.statusItems.check(value) + self._status = self.theProject.data.itemStatus.check(value) return def setImport(self, value): """Set the item importance by looking it up in the valid import items of the current project. """ - self._import = self.theProject.importItems.check(value) + self._import = self.theProject.data.itemImport.check(value) return def setActive(self, state): @@ -532,25 +480,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..cf48c23d 100644 --- a/novelwriter/core/project.py +++ b/novelwriter/core/project.py @@ -4,7 +4,8 @@ novelWriter – Project Wrapper Data class for novelWriter projects File History: -Created: 2018-09-29 [0.0.1] +Created: 2018-09-29 [0.0.1] NWProject +Created: 2022-10-30 [2.0rc1] NWProjectData This file is a part of novelWriter Copyright 2018–2022, Veronica Berglyd Olsen @@ -23,6 +24,8 @@ You should have received a copy of the GNU General Public License along with this program. If not, see . """ +from __future__ import annotations + import os import json import shutil @@ -30,38 +33,43 @@ import logging import novelwriter from time import time -from lxml import etree from functools import partial -from PyQt5.QtCore import QCoreApplication +from PyQt5.QtCore import QCoreApplication, QObject, pyqtSignal +from novelwriter.enum import nwItemType, nwItemClass, nwItemLayout, nwAlert +from novelwriter.error import logException +from novelwriter.constants import trConst, nwFiles, nwLabels 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.core.projectxml import ProjectXMLReader, ProjectXMLWriter, XMLReadState from novelwriter.common import ( - checkString, checkBool, checkInt, checkStringNone, isHandle, formatTimeStamp, - makeFileNameSafe, hexToInt, minmax, simplified + checkBool, checkInt, checkStringNone, formatTimeStamp, hexToInt, isHandle, + makeFileNameSafe, minmax, simplified, ) -from novelwriter.constants import trConst, nwFiles, nwLabels + logger = logging.getLogger(__name__) -class NWProject: +class NWProject(QObject): - FILE_VERSION = "1.4" # The current project file format version + projectStatusChanged = pyqtSignal(bool) def __init__(self, mainGui): + super().__init__(parent=mainGui) # Internal self.mainConf = novelwriter.CONFIG self.mainGui = mainGui + # Project Data + self._data = NWProjectData(self) + # Core Elements self._optState = OptionState(self) # Project-specific GUI options self._projTree = NWTree(self) # The project tree @@ -69,13 +77,10 @@ class NWProject: self._langData = {} # Localisation data # Project Status - self.projOpened = 0 # The time stamp of when the project file was opened - self.projChanged = False # The project has unsaved changes - self.projAltered = False # The project has been altered this session - self.lockedBy = None # Data on which computer has the project open - self.saveCount = 0 # Meta data: number of saves - self.autoCount = 0 # Meta data: number of automatic saves - self.editTime = 0 # The accumulated edit time read from the project file + self._projOpened = 0 # The time stamp of when the project file was opened + self._projChanged = False # The project has unsaved changes + self._projAltered = False # The project has been altered this session + self.lockedBy = None # Data on which computer has the project open # Class Settings self.projPath = None # The full path to where the currently open project is saved @@ -83,33 +88,8 @@ class NWProject: self.projCache = None # The full path to the project's cache folder self.projContent = None # The full path to the project's content folder self.projDict = None # The spell check dictionary - self.projSpell = None # The spell check language, if different than default - self.projLang = None # The project language, used for builds self.projFiles = [] # A list of all files in the content folder on load - # Project Meta - self.projName = "" # Project name - self.bookTitle = "" # The final title; should only be used for exports - self.bookAuthors = [] # A list of book authors - - # Project Settings - self.autoReplace = {} # Text to auto-replace on exports - self.titleFormat = {} # The formatting of titles for exports - self.spellCheck = False # Controls the spellcheck-as-you-type feature - self.statusItems = None # Novel file progress status values - self.importItems = None # Note file importance values - self.lastEdited = None # The handle of the last file to be edited - self.lastViewed = None # The handle of the last file to be viewed - self.lastNovel = None # The handle of the last novel root viewed - self.lastOutline = None # The handle of the last outline root viewed - self.lastWCount = 0 # The project word count from last session - self.lastNovelWC = 0 # The novel files word count from last session - self.lastNotesWC = 0 # The note files word count from last session - self.currWCount = 0 # The project word count in current session - self.currNovelWC = 0 # The novel files word count in cutrent session - self.currNotesWC = 0 # The note files word count in cutrent session - self.doBackup = True # Run project backup on exit - # Internal Mapping self.tr = partial(QCoreApplication.translate, "NWProject") @@ -122,6 +102,10 @@ class NWProject: # Properties ## + @property + def data(self): + return self._data + @property def index(self): return self._projIndex @@ -134,6 +118,18 @@ class NWProject: def options(self): return self._optState + @property + def projOpened(self): + return self._projOpened + + @property + def projChanged(self): + return self._projChanged + + @property + def projAltered(self): + return self._projAltered + ## # Item Methods ## @@ -242,54 +238,22 @@ class NWProject: default values. """ # Project Status - self.projOpened = 0 - self.projChanged = False - self.projAltered = False - self.saveCount = 0 - self.autoCount = 0 + self._projOpened = 0 + self._projChanged = False + self._projAltered = False # Project Tree self._projTree.clear() + self._data = NWProjectData(self) + # Project Settings self.projPath = None self.projMeta = None self.projCache = None self.projContent = None self.projDict = None - self.projSpell = None - self.projLang = None self.projFiles = [] - self.projName = "" - self.bookTitle = "" - self.bookAuthors = [] - self.autoReplace = {} - self.titleFormat = { - "title": "%title%", - "chapter": "%title%", - "unnumbered": "%title%", - "scene": "* * *", - "section": "", - } - self.spellCheck = False - self.statusItems = NWStatus(NWStatus.STATUS) - self.statusItems.write(None, self.tr("New"), (100, 100, 100)) - self.statusItems.write(None, self.tr("Note"), (200, 50, 0)) - self.statusItems.write(None, self.tr("Draft"), (200, 150, 0)) - self.statusItems.write(None, self.tr("Finished"), (50, 200, 0)) - self.importItems = NWStatus(NWStatus.IMPORT) - self.importItems.write(None, self.tr("New"), (100, 100, 100)) - self.importItems.write(None, self.tr("Minor"), (200, 50, 0)) - self.importItems.write(None, self.tr("Major"), (200, 150, 0)) - self.importItems.write(None, self.tr("Main"), (50, 200, 0)) - self.lastEdited = None - self.lastViewed = None - self.lastWCount = 0 - self.lastNovelWC = 0 - self.lastNotesWC = 0 - self.currWCount = 0 - self.currNovelWC = 0 - self.currNotesWC = 0 return @@ -322,19 +286,32 @@ class NWProject: return False self.clearProject() + + self._data.itemStatus.write(None, self.tr("New"), (100, 100, 100)) + self._data.itemStatus.write(None, self.tr("Note"), (200, 50, 0)) + self._data.itemStatus.write(None, self.tr("Draft"), (200, 150, 0)) + self._data.itemStatus.write(None, self.tr("Finished"), (50, 200, 0)) + + self._data.itemImport.write(None, self.tr("New"), (100, 100, 100)) + self._data.itemImport.write(None, self.tr("Minor"), (200, 50, 0)) + self._data.itemImport.write(None, self.tr("Major"), (200, 150, 0)) + self._data.itemImport.write(None, self.tr("Main"), (50, 200, 0)) + if not self.setProjectPath(projPath, newProject=True): return False - self.setProjectName(projName) - self.setBookTitle(projTitle) - self.setBookAuthors(projAuthors) + self._data.setName(projName) + self._data.setTitle(projTitle) + self._data.setAuthors(projAuthors) hNovelRoot = self.newRoot(nwItemClass.NOVEL) hTitlePage = self.newFile(self.tr("Title Page"), hNovelRoot) - titlePage = "#! %s\n\n" % (self.bookTitle if self.bookTitle else self.projName) - if self.bookAuthors: - titlePage = "%s>> %s %s <<\n" % (titlePage, self.tr("By"), self.getAuthors()) + titlePage = "#! %s\n\n" % (self._data.title if self._data.title else self._data.name) + if self._data.authors: + titlePage = "%s>> %s %s <<\n" % ( + titlePage, self.tr("By"), self.getFormattedAuthors() + ) aDoc = NWDoc(self, hTitlePage) aDoc.writeDocument(titlePage) @@ -414,7 +391,7 @@ class NWProject: # Finalise if popCustom or popMinimal: - self.projOpened = time() + self._projOpened = time() self.setProjectChanged(True) self.saveProject(autoSave=True) @@ -477,80 +454,38 @@ 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) + self._data = NWProjectData(self) + projContent = [] - # Trying to open backup file instead - backFile = fileName[:-3]+"bak" - if os.path.isfile(backFile): + xmlReader = ProjectXMLReader(fileName) + xmlParsed = xmlReader.read(self._data, projContent) + + appVersion = xmlReader.appVersion or self.tr("Unknown") + hexVersion = xmlReader.hexVersion or "0x0" + + 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 - - appVersion = xRoot.attrib.get("appVersion", self.tr("Unknown")) - hexVersion = xRoot.attrib.get("hexVersion", "0x0") - fileVersion = xRoot.attrib.get("fileVersion", self.tr("Unknown")) - - logger.debug("XML root is '%s'", nwxRoot) - logger.debug("File version is '%s'", fileVersion) - - # Check File Type - # =============== - - 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 - # ============================= + # Check Legacy Upgrade + # ==================== - # 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,80 +516,10 @@ class NWProject: self.clearProject() return False - # Start Parsing the XML - # ===================== - - 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) - - 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) - - elif xChild.tag == "content": - logger.debug("Found project content") - self._projTree.unpackXML(xChild) + # Extract Data + # ============ + self._projTree.unpack(projContent) self._optState.loadSettings() # Sort out old file locations @@ -672,7 +537,9 @@ class NWProject: self._deprecatedFiles() # Update recent projects - self.mainConf.updateRecentCache(self.projPath, self.projName, self.lastWCount, time()) + self.mainConf.updateRecentCache( + self.projPath, self._data.name, sum(self._data.initCounts), time() + ) self.mainConf.saveRecentCache() # Check the project tree consistency @@ -687,12 +554,12 @@ class NWProject: self._loadProjectLocalisation() self.updateWordCounts() - self.projOpened = time() - self.projAltered = False + self._projOpened = time() + self._projAltered = False self._writeLockFile() self.setProjectChanged(False) - self.mainGui.setStatus(self.tr("Opened Project: {0}").format(self.projName)) + self.mainGui.setStatus(self.tr("Opened Project: {0}").format(self._data.name)) return True @@ -715,101 +582,35 @@ class NWProject: logger.info("Saving project: %s", self.projPath) if autoSave: - self.autoCount += 1 + self._data.incAutoCount() else: - self.saveCount += 1 - - # 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._data.incSaveCount() self.updateWordCounts() - editTime = int(self.editTime + saveTime - self.projOpened) - - # Save Project Meta - xProject = etree.SubElement(nwXML, "project") - self._packProjectValue(xProject, "name", self.projName) - self._packProjectValue(xProject, "title", self.bookTitle) - self._packProjectValue(xProject, "author", self.bookAuthors) - self._packProjectValue(xProject, "saveCount", str(self.saveCount)) - self._packProjectValue(xProject, "autoCount", str(self.autoCount)) - self._packProjectValue(xProject, "editTime", str(editTime)) - - # Save Project Settings - xSettings = etree.SubElement(nwXML, "settings") - self._packProjectValue(xSettings, "doBackup", self.doBackup) - self._packProjectValue(xSettings, "language", self.projLang) - self._packProjectValue(xSettings, "spellCheck", self.spellCheck) - self._packProjectValue(xSettings, "spellLang", self.projSpell) - self._packProjectValue(xSettings, "lastEdited", self.lastEdited) - self._packProjectValue(xSettings, "lastViewed", self.lastViewed) - self._packProjectValue(xSettings, "lastNovel", self.lastNovel) - self._packProjectValue(xSettings, "lastOutline", self.lastOutline) - self._packProjectValue(xSettings, "lastWordCount", self.currWCount) - self._packProjectValue(xSettings, "novelWordCount", self.currNovelWC) - self._packProjectValue(xSettings, "notesWordCount", self.currNotesWC) - self._packProjectKeyValue(xSettings, "autoReplace", self.autoReplace) - - xTitleFmt = etree.SubElement(xSettings, "titleFormat") - for aKey, aValue in self.titleFormat.items(): - if len(aKey) > 0: - self._packProjectValue(xTitleFmt, aKey, aValue) - - # Save Status/Importance self.countStatus() - xStatus = etree.SubElement(xSettings, "status") - self.statusItems.packXML(xStatus) - xStatus = etree.SubElement(xSettings, "importance") - self.importItems.packXML(xStatus) - # Save Tree Content - logger.debug("Writing project content") - self._projTree.packXML(nwXML) + saveTime = time() + editTime = int(self._data.editTime + saveTime - self._projOpened) - # 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: + 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, 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) + ), nwAlert.ERROR, exception=xmlWriter.error) return False # Save project GUI options self._optState.saveSettings() # Update recent projects - self.mainConf.updateRecentCache(self.projPath, self.projName, self.currWCount, saveTime) + self.mainConf.updateRecentCache( + self.projPath, self._data.name, sum(self._data.currCounts), saveTime + ) self.mainConf.saveRecentCache() self._writeLockFile() - self.mainGui.setStatus(self.tr("Saved Project: {0}").format(self.projName)) + self.mainGui.setStatus(self.tr("Saved Project: {0}").format(self._data.name)) self.setProjectChanged(False) return True @@ -871,14 +672,14 @@ class NWProject: ), nwAlert.ERROR) return False - if not self.projName: + if not self._data.name: self.mainGui.makeAlert(self.tr( "Cannot backup project because no project name is set. " "Please set a Working Title in Project Settings." ), nwAlert.ERROR) return False - cleanName = makeFileNameSafe(self.projName) + cleanName = makeFileNameSafe(self._data.name) baseDir = os.path.abspath(os.path.join(self.mainConf.backupPath, cleanName)) if not os.path.isdir(baseDir): try: @@ -1024,84 +825,12 @@ class NWProject: return True - def setProjectName(self, projName): - """Set the project name, This is the the name used for backup - files etc. - """ - self.projName = simplified(projName) - self.setProjectChanged(True) - return True - - def setBookTitle(self, bookTitle): - """Set the book title, that is, the title to include in exports. - """ - self.bookTitle = simplified(bookTitle) - self.setProjectChanged(True) - return True - - def setBookAuthors(self, bookAuthors): - """A line-separated list of authors, parsed into an array. - """ - if not isinstance(bookAuthors, str): - return False - - self.bookAuthors = [] - for bookAuthor in bookAuthors.splitlines(): - bookAuthor = simplified(bookAuthor) - if bookAuthor == "": - continue - self.bookAuthors.append(bookAuthor) - - self.setProjectChanged(True) - - return True - - def setProjBackup(self, doBackup): - """Set whether projects should be backed up or not. The user - will be notified in case required settings are missing. - """ - self.doBackup = doBackup - if doBackup: - if not os.path.isdir(self.mainConf.backupPath): - self.mainGui.makeAlert(self.tr( - "You must set a valid backup path in Preferences to use " - "the automatic project backup feature." - ), nwAlert.WARN) - return False - - if self.projName == "": - self.mainGui.makeAlert(self.tr( - "You must set a valid project name in Project Settings to " - "use the automatic project backup feature." - ), nwAlert.WARN) - return False - - return True - - def setSpellCheck(self, theMode): - """Enable/disable spell checking. - """ - if self.spellCheck != theMode: - self.spellCheck = theMode - self.setProjectChanged(True) - return self.spellCheck - - def setSpellLang(self, theLang): - """Set the project-specific spell check language. - """ - theLang = checkStringNone(theLang, None) - if self.projSpell != theLang: - self.projSpell = theLang - self.setProjectChanged(True) - return True - return False - def setProjectLang(self, theLang): """Set the project-specific language. """ theLang = checkStringNone(theLang, None) - if self.projLang != theLang: - self.projLang = theLang + if self._data.language != theLang: + self._data.setLanguage(theLang) self._loadProjectLocalisation() self.setProjectChanged(True) return True @@ -1117,102 +846,53 @@ class NWProject: self.setProjectChanged(True) return True - def setLastEdited(self, tHandle): - """Set last edited project item. - """ - if self.lastEdited != tHandle: - self.lastEdited = tHandle - self.setProjectChanged(True) - return True - - def setLastViewed(self, tHandle): - """Set last viewed project item. - """ - if self.lastViewed != tHandle: - self.lastViewed = tHandle - self.setProjectChanged(True) - return True - - def setLastNovelViewed(self, tHandle): - """Set last viewed novel root in the novel tree. - """ - if self.lastNovel != tHandle: - self.lastNovel = tHandle - self.setProjectChanged(True) - return True - - def setLastOutlineViewed(self, tHandle): - """Set last viewed novel root in the outline view. - """ - if self.lastOutline != tHandle: - self.lastOutline = tHandle - self.setProjectChanged(True) - return True - def setStatusColours(self, newCols, delCols): """Update the list of novel file status flags. """ - return self._setStatusImport(newCols, delCols, self.statusItems) + return self._setStatusImport(newCols, delCols, self._data.itemStatus) def setImportColours(self, newCols, delCols): """Update the list of note file importance flags. """ - return self._setStatusImport(newCols, delCols, self.importItems) + return self._setStatusImport(newCols, delCols, self._data.itemImport) - def setAutoReplace(self, autoReplace): - """Update the auto-replace dictionary. - """ - self.autoReplace = {} - for key, entry in autoReplace.items(): - self.autoReplace[key] = simplified(entry) - self.setProjectChanged(True) - return True - - def setTitleFormat(self, titleFormat): - """Set the formatting of titles in the project. - """ - for valKey, valEntry in titleFormat.items(): - if valKey in self.titleFormat: - self.titleFormat[valKey] = checkString( - simplified(valEntry), self.titleFormat[valKey] - ) - return True - - def setProjectChanged(self, bValue): + def setProjectChanged(self, value): """Toggle the project changed flag, and propagate the information to the GUI statusbar. """ - self.projChanged = bValue - self.mainGui.mainStatus.doUpdateProjectStatus(bValue) - if bValue: - # If we've changed the project at all, this should be True - self.projAltered = True - return self.projChanged + if isinstance(value, bool): + self._projChanged = value + self.projectStatusChanged.emit(self._projChanged) + if value: + # If we've changed the project at all, this should be True + self._projAltered = True + return self._projChanged ## # Getters ## - def getAuthors(self): + def getFormattedAuthors(self): """Return a formatted string of authors. """ - nAuth = len(self.bookAuthors) - authString = "" + authors = self._data.authors + nAuth = len(authors) + result = "" if nAuth == 1: - authString = self.bookAuthors[0] + result = authors[0] elif nAuth > 1: - authString = "%s %s %s" % ( - ", ".join(self.bookAuthors[0:-1]), self.tr("and"), self.bookAuthors[-1] + result = "%s %s %s" % ( + ", ".join(authors[0:-1]), self.tr("and"), authors[-1] ) - return authString + return result def getCurrentEditTime(self): """Get the total project edit time, including the time spent in the current session. """ - return round(self.editTime + time() - self.projOpened) + return round(self._data.editTime + time() - self._projOpened) def getProjectItems(self): """This function ensures that the item tree loaded is sent to @@ -1263,13 +943,8 @@ class NWProject: def updateWordCounts(self): """Update the total word count values. """ - wcNovel, wcNotes = self._projTree.sumWords() - wcTotal = wcNovel + wcNotes - if wcTotal != self.currWCount: - self.currNovelWC = wcNovel - self.currNotesWC = wcNotes - self.currWCount = wcTotal - self.setProjectChanged(True) + novel, notes = self._projTree.sumWords() + self._data.setCurrCounts(novel=novel, notes=notes) return def countStatus(self): @@ -1277,13 +952,13 @@ class NWProject: project tree. The counts themselves are kept in the NWStatus objects. This is essentially a refresh. """ - self.statusItems.resetCounts() - self.importItems.resetCounts() + self._data.itemStatus.resetCounts() + self._data.itemImport.resetCounts() for nwItem in self._projTree: if nwItem.isNovelLike(): - self.statusItems.increment(nwItem.itemStatus) + self._data.itemStatus.increment(nwItem.itemStatus) else: - self.importItems.increment(nwItem.itemImport) + self._data.itemImport.increment(nwItem.itemImport) return def localLookup(self, theWord): @@ -1322,11 +997,11 @@ class NWProject: def _loadProjectLocalisation(self): """Load the language data for the current project language. """ - if self.projLang is None: + if self._data.language is None: self._langData = {} return False - langFile = os.path.join(self.mainConf.nwLangPath, "project_%s.json" % self.projLang) + langFile = os.path.join(self.mainConf.nwLangPath, "project_%s.json" % self._data.language) if not os.path.isfile(langFile): langFile = os.path.join(self.mainConf.nwLangPath, "project_en_GB.json") @@ -1419,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 @@ -1550,8 +1203,11 @@ class NWProject: isFile = os.path.isfile(sessionFile) nowTime = time() - sessDiff = self.currWCount - self.lastWCount - sessTime = nowTime - self.projOpened + iNovel, iNotes = self._data.initCounts + cNovel, cNotes = self._data.currCounts + iTotal = iNovel + iNotes + sessDiff = cNovel + cNotes - iTotal + sessTime = nowTime - self._projOpened logger.info("The session lasted %d sec and added %d words", int(sessTime), sessDiff) if sessTime < 300 and sessDiff == 0: @@ -1562,17 +1218,17 @@ class NWProject: with open(sessionFile, mode="a+", encoding="utf-8") as outFile: if not isFile: # It's a new file, so add a header - if self.lastWCount > 0: - outFile.write("# Offset %d\n" % self.lastWCount) + if iTotal > 0: + outFile.write("# Offset %d\n" % iTotal) outFile.write("# %-17s %-19s %8s %8s %8s\n" % ( "Start Time", "End Time", "Novel", "Notes", "Idle" )) outFile.write("%-19s %-19s %8d %8d %8d\n" % ( - formatTimeStamp(self.projOpened), + formatTimeStamp(self._projOpened), formatTimeStamp(nowTime), - self.currNovelWC, - self.currNotesWC, + cNovel, + cNotes, int(idleTime), )) @@ -1655,3 +1311,305 @@ class NWProject: return True # END Class NWProject + + +class NWProjectData: + + def __init__(self, theProject): + + self.theProject = theProject + + # Project Meta + self._name = "" + self._title = "" + self._authors = [] + self._saveCount = 0 + self._autoCount = 0 + self._editTime = 0 + + # Project Settings + self._doBackup = True + self._language = None + self._spellCheck = False + self._spellLang = None + + # Project Dictionaries + self._initCounts = [0, 0] + self._currCounts = [0, 0] + self._lastHandle: dict[str, str | None] = { + "editor": None, + "viewer": None, + "novelTree": None, + "outline": None, + } + self._autoReplace: dict[str, str] = {} + self._titleFormat: dict[str, str] = { + "title": "%title%", + "chapter": "%title%", + "unnumbered": "%title%", + "scene": "* * *", + "section": "", + } + + self._status = NWStatus(NWStatus.STATUS) + self._import = NWStatus(NWStatus.IMPORT) + + return + + ## + # Properties + ## + + @property + def name(self): + return self._name + + @property + def title(self): + return self._title + + @property + def authors(self): + return self._authors + + @property + def saveCount(self): + return self._saveCount + + @property + def autoCount(self): + return self._autoCount + + @property + def editTime(self): + return self._editTime + + @property + def doBackup(self): + return self._doBackup + + @property + def language(self): + return self._language + + @property + def spellCheck(self): + return self._spellCheck + + @property + def spellLang(self): + return self._spellLang + + @property + def initCounts(self): + return tuple(self._initCounts) + + @property + def currCounts(self): + return tuple(self._currCounts) + + @property + def lastHandle(self): + return self._lastHandle + + @property + def autoReplace(self): + return self._autoReplace + + @property + def titleFormat(self): + return self._titleFormat + + @property + def itemStatus(self): + return self._status + + @property + def itemImport(self): + return self._import + + ## + # Methods + ## + + def addAuthor(self, value): + """Add an author to the authors list. + """ + self._authors.append(simplified(str(value))) + self.theProject.setProjectChanged(True) + return + + def incSaveCount(self): + """Increment the save count by one. + """ + self._saveCount += 1 + self.theProject.setProjectChanged(True) + return + + def incAutoCount(self): + """Increment the auto save count by one. + """ + self._autoCount += 1 + self.theProject.setProjectChanged(True) + return + + ## + # Getters + ## + + def getLastHandle(self, component): + """Retrieve the last used handle for a given component. + """ + return self._lastHandle.get(component, None) + + def getTitleFormat(self, kind): + """Retrieve the title format string for a given kind of header. + """ + return self._titleFormat.get(kind, "%title%") + + ## + # Setters + ## + + def setName(self, value): + """Set a new project name. + """ + if value != self._name: + self._name = simplified(str(value)) + self.theProject.setProjectChanged(True) + return + + def setTitle(self, value): + """Set a new novel title. + """ + if value != self._title: + self._title = simplified(str(value)) + self.theProject.setProjectChanged(True) + return + + def setAuthors(self, value): + """Set the list of authors from either a string with one author + per line, or a list of authors. + """ + self._authors = [] + self.theProject.setProjectChanged(True) + if isinstance(value, str): + for author in value.splitlines(): + author = simplified(author) + if author: + self._authors.append(author) + self.theProject.setProjectChanged(True) + elif isinstance(value, list): + self._authors = value + return + + def setSaveCount(self, value): + """Set the save count from last session. + """ + self._saveCount = checkInt(value, 0) + self.theProject.setProjectChanged(True) + return + + def setAutoCount(self, value): + """Set the auto save count from last session. + """ + self._autoCount = checkInt(value, 0) + self.theProject.setProjectChanged(True) + return + + def setEditTime(self, value): + """Set tyje edit time from last session. + """ + self._editTime = checkInt(value, 0) + self.theProject.setProjectChanged(True) + return + + def setDoBackup(self, value): + """Set the do write backup flag. + """ + if value != self._doBackup: + self._doBackup = checkBool(value, False) + self.theProject.setProjectChanged(True) + return + + def setLanguage(self, value): + """Set the project language. + """ + if value != self._language: + self._language = checkStringNone(value, None) + self.theProject.setProjectChanged(True) + return + + def setSpellCheck(self, value): + """Set the spell check flag. + """ + if value != self._spellCheck: + self._spellCheck = checkBool(value, False) + self.theProject.setProjectChanged(True) + return + + def setSpellLang(self, value): + """Set the spell check language. + """ + if value != self._spellLang: + self._spellLang = checkStringNone(value, None) + self.theProject.setProjectChanged(True) + return + + def setLastHandle(self, value, component=None): + """Set a last used handle into the handle registry. If component + is None, the value is assumed to be the whole dictionary of + values. + """ + if isinstance(component, str): + self._lastHandle[component] = checkStringNone(value, None) + self.theProject.setProjectChanged(True) + elif isinstance(value, dict): + for key, entry in value.items(): + if key in self._lastHandle: + self._lastHandle[key] = str(entry) if isHandle(entry) else None + self.theProject.setProjectChanged(True) + return + + def setInitCounts(self, novel=None, notes=None): + """Set the worc count totals for novel and note files. + """ + if novel is not None: + self._initCounts[0] = checkInt(novel, 0) + self._currCounts[0] = checkInt(novel, 0) + if notes is not None: + self._initCounts[1] = checkInt(notes, 0) + self._currCounts[1] = checkInt(notes, 0) + return + + def setCurrCounts(self, novel=None, notes=None): + """Set the worc count totals for novel and note files. + """ + if novel is not None: + self._currCounts[0] = checkInt(novel, 0) + if notes is not None: + self._currCounts[1] = checkInt(notes, 0) + return + + def setAutoReplace(self, value): + """Set the auto-replace dictionary. + """ + if isinstance(value, dict): + self._autoReplace = {} + for key, entry in value.items(): + if isinstance(entry, str): + self._autoReplace[key] = simplified(entry) + self.theProject.setProjectChanged(True) + return + + def setTitleFormat(self, value): + """Set the title formats. + """ + if isinstance(value, dict): + for key, entry in value.items(): + if key in self._titleFormat and isinstance(entry, str): + self._titleFormat[key] = simplified(entry) + self.theProject.setProjectChanged(True) + return + +# END Class NWProjectData diff --git a/novelwriter/core/projectxml.py b/novelwriter/core/projectxml.py new file mode 100644 index 00000000..dc08573a --- /dev/null +++ b/novelwriter/core/projectxml.py @@ -0,0 +1,557 @@ +""" +novelWriter – Project XML Read/Write +==================================== +Classes for reading and writing the project XML file + +File History: +Created: 2022-09-28 [2.0rc1] ProjectXMLReader +Created: 2022-09-28 [2.0rc1] XMLReadState + +This file is a part of novelWriter +Copyright 2018–2022, Veronica Berglyd Olsen + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, but +WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +""" + +import os +import logging +import novelwriter + +from enum import Enum +from lxml import etree +from time import time + +from novelwriter.common import ( + 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, + "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 + NOT_NWX_FILE = 4 + UNKNOWN_VERSION = 5 + PARSED_OK = 6 + WAS_LEGACY = 7 + +# END Class XMLReadState + + +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): + + self._path = path + self._state = XMLReadState.NO_ACTION + + self._root = "" + self._version = 0x0000 + self._appVersion = "" + self._hexVersion = "" + self._timeStamp = "" + + return + + ## + # Properties + ## + + @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 + + ## + # Methods + ## + + def read(self, projData, projContent): + """Read and parse the project XML file. + """ + tStart = time() + logger.debug("Reading project XML") + + try: + xml = etree.parse(self._path) + self._state = XMLReadState.NO_ERROR + + except Exception as exc: + # Trying to open backup file instead + logger.error("Failed to parse project xml", exc_info=exc) + self._state = XMLReadState.CANNOT_PARSE + + backFile = self._path[:-3]+"bak" + if os.path.isfile(backFile): + try: + xml = etree.parse(backFile) + self._state = XMLReadState.PARSED_BACKUP + logger.info("Backup project file parsed") + except Exception as exc: + logger.error("Failed to parse backup project xml", exc_info=exc) + self._state = XMLReadState.CANNOT_PARSE + return False + else: + self._state = XMLReadState.CANNOT_PARSE + return False + + xRoot = xml.getroot() + self._root = str(xRoot.tag) + if self._root != "novelWriterXML": + self._state = XMLReadState.NOT_NWX_FILE + return False + + fileVersion = str(xRoot.attrib.get("fileVersion", "")) + if fileVersion in NUM_VERSION: + self._version = NUM_VERSION[fileVersion] + else: + 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("hexVersion", "")) + self._timeStamp = str(xRoot.attrib.get("timeStamp", "")) + + for xSection in xRoot: + if xSection.tag == "project": + self._parseProjectMeta(xSection, projData) + elif xSection.tag == "settings": + self._parseProjectSettings(xSection, projData) + elif xSection.tag == "content": + if self._version >= 0x0104: + self._parseProjectContent(xSection, projContent) + else: + self._parseProjectContentLegacy(xSection, projContent, projData) + else: + logger.warning("Ignored in xml", xSection.tag) + + if self._version == 0x0104: + self._state = XMLReadState.PARSED_OK + else: + self._state = XMLReadState.WAS_LEGACY + + logger.debug("Project XML loaded in %.3f ms", (time() - tStart)*1000) + + return True + + ## + # Internal Functions + ## + + def _parseProjectMeta(self, xSection, projData): + """Parse the project section of the XML file. + """ + logger.debug("Parsing section") + for xItem in xSection: + if xItem.tag == "name": + projData.setName(xItem.text) + elif xItem.tag == "title": + projData.setTitle(xItem.text) + elif xItem.tag == "author": + projData.addAuthor(xItem.text) + elif xItem.tag == "saveCount": + projData.setSaveCount(xItem.text) + elif xItem.tag == "autoCount": + projData.setAutoCount(xItem.text) + elif xItem.tag == "editTime": + projData.setEditTime(xItem.text) + else: + logger.warning("Ignored in xml", xItem.tag) + + return + + def _parseProjectSettings(self, xSection, projData): + """Parse the settings section of the XML file. + """ + logger.debug("Parsing section") + + for xItem in xSection: + if xItem.tag == "doBackup": + projData.setDoBackup(xItem.text) + elif xItem.tag == "language": + projData.setLanguage(xItem.text) + elif xItem.tag == "spellCheck": + projData.setSpellCheck(xItem.text) + elif xItem.tag == "spellLang": + projData.setSpellLang(xItem.text) + elif xItem.tag == "novelWordCount": + projData.setInitCounts(novel=xItem.text) + elif xItem.tag == "notesWordCount": + projData.setInitCounts(notes=xItem.text) + elif xItem.tag == "status": + self._parseStatusImport(xItem, projData.itemStatus) + elif xItem.tag in ("import", "importance"): + self._parseStatusImport(xItem, projData.itemImport) + elif xItem.tag == "lastHandle": + projData.setLastHandle(self._parseDictKeyText(xItem)) + elif xItem.tag == "autoReplace": + if self._version >= 0x0102: + projData.setAutoReplace(self._parseDictKeyText(xItem)) + else: # Pre 1.2 format + projData.setAutoReplace(self._parseDictTagText(xItem)) + elif xItem.tag == "titleFormat": + if self._version >= 0x0104: + projData.setTitleFormat(self._parseDictKeyText(xItem)) + else: # Pre 1.4 format + projData.setTitleFormat(self._parseDictTagText(xItem)) + else: + logger.warning("Ignored in xml", xItem.tag) + + return + + def _parseProjectContent(self, xSection, projContent): + """Parse the content section of the XML file. + """ + logger.debug("Parsing section") + + for xItem in xSection: + if xItem.tag == "item": + item = {} + 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) + 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) + 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) + else: + logger.warning("Ignored in xml", xVal.tag) + + projContent.append(item) + + else: + logger.warning("Ignored item in xml", xItem.tag) + + return + + def _parseProjectContentLegacy(self, xSection, projContent, projData): + """Parse the content section of the XML file for older versions. + """ + 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 = {} + if xItem.tag == "item": + item["handle"] = checkStringNone(xItem.attrib.get("handle", None), None) + item["parent"] = checkStringNone(xItem.attrib.get("parent", None), 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"] = statusMap.get(tmpStatus, None) + else: + item["import"] = importMap.get(tmpStatus, None) + + # A number of layouts were removed in 1.3 + 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" + + projContent.append(item) + + else: + logger.warning("Ignored in xml", xItem.tag) + + return + + def _parseStatusImport(self, xItem, sObject): + """Parse a status or importance entry. + """ + for xEntry in xItem: + if xEntry.tag == "entry": + key = xEntry.attrib.get("key", None) + red = checkInt(xEntry.attrib.get("red", 0), 0) + green = checkInt(xEntry.attrib.get("green", 0), 0) + blue = checkInt(xEntry.attrib.get("blue", 0), 0) + count = checkInt(xEntry.attrib.get("count", 0), 0) + sObject.write(key, xEntry.text, (red, green, blue), count) + return + + def _parseDictKeyText(self, xItem): + """Parse a dictionary stored with key as an attribute and the + value as the text porperty. + """ + result = {} + for xEntry in xItem: + if xEntry.tag == "entry" and "key" in xEntry.attrib: + result[xEntry.attrib["key"]] = checkString(xEntry.text, "") + return result + + def _parseDictTagText(self, xItem): + """Parse a dictionary stored with key as the tag and the value + as the text porperty. + """ + return {n.tag: checkString(n.text, "") for n in xItem} + +# END Class ProjectXMLReader + + +class ProjectXMLWriter: + + def __init__(self, path): + + self._path = path + self._error = None + + return + + ## + # Properties + ## + + @property + def error(self): + return self._error + + ## + # Methods + ## + + def write(self, projData, projContent, saveTime, editTime): + """Write the project data and content to the XML files. + """ + tStart = time() + logger.debug("Writing project XML") + + xRoot = etree.Element("novelWriterXML", attrib={ + "appVersion": str(novelwriter.__version__), + "hexVersion": str(novelwriter.__hexversion__), + "fileVersion": FILE_VERSION, + "timeStamp": formatTimeStamp(saveTime), + }) + + # Save Project Meta + xProject = etree.SubElement(xRoot, "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(xRoot, "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, "novelWordCount", projData.currCounts[0]) + self._packSingleValue(xSettings, "notesWordCount", projData.currCounts[1]) + self._packDictKeyValue(xSettings, "lastHandle", projData.lastHandle) + self._packDictKeyValue(xSettings, "autoReplace", projData.autoReplace) + self._packDictKeyValue(xSettings, "titleFormat", projData.titleFormat) + + # Save Status/Importance + xStatus = etree.SubElement(xSettings, "status") + for label, attrib in projData.itemStatus.pack(): + self._packSingleValue(xStatus, "entry", label, attrib=attrib) + + xImport = etree.SubElement(xSettings, "importance") + for label, attrib in projData.itemImport.pack(): + self._packSingleValue(xImport, "entry", label, attrib=attrib) + + # Save Tree Content + 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", {})) + xName = etree.SubElement(xItem, "name", attrib=item.get("nameAttr", {})) + xName.text = item["name"] + + # Write the xml tree to file + saveFile = os.path.join(self._path, nwFiles.PROJ_FILE) + tempFile = 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( + xRoot, + pretty_print=True, + encoding="utf-8", + xml_declaration=True + )) + except Exception as exc: + self._error = 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._error = exc + return False + + logger.debug("Project XML saved in %.3f ms", (time() - tStart)*1000) + + return True + + ## + # Internal Functions + ## + + def _packSingleValue(self, xParent, name, value, attrib=None): + """Pack a single value into an xml element. + """ + xItem = etree.SubElement(xParent, name, attrib=attrib) + xItem.text = str(value) or "" + return + + def _packListValue(self, xParent, name, data): + """Pack a list of values into an xml element. + """ + for value in data: + xItem = etree.SubElement(xParent, name) + xItem.text = str(value) or "" + 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 = str(value) or "" + return + +# END Class ProjectXMLWriter diff --git a/novelwriter/core/status.py b/novelwriter/core/status.py index c466461e..fea6397d 100644 --- a/novelwriter/core/status.py +++ b/novelwriter/core/status.py @@ -28,12 +28,10 @@ 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 -from novelwriter.common import checkInt, minmax, simplified +from novelwriter.common import minmax, simplified logger = logging.getLogger(__name__) @@ -47,7 +45,6 @@ class NWStatus: self._type = type self._store = {} - self._reverse = {} self._default = None self._iPX = novelwriter.CONFIG.pxInt(24) @@ -58,7 +55,7 @@ class NWStatus: self._iconPath = QPainterPath() self._iconPath.addRoundedRect(QRectF(pA, pA, pB, pB), pR, pR) - self._defaultIcon = self._createIcon([100, 100, 100]) + self._defaultIcon = self._createIcon(100, 100, 100) if self._type == self.STATUS: self._prefix = "s" @@ -80,17 +77,19 @@ class NWStatus: if len(col) != 3: col = (100, 100, 100) + cR = minmax(col[0], 0, 255) + cG = minmax(col[1], 0, 255) + cB = minmax(col[2], 0, 255) name = simplified(name) if count is None: - count = self._store[key]["count"] if key in self._store else 0 + count = self._store.get(key, {}).get("count", 0) self._store[key] = { "name": name, - "icon": self._createIcon(col), - "cols": col, + "icon": self._createIcon(cR, cG, cB), + "cols": (cR, cG, cB), "count": count, } - self._reverse[name] = key if self._default is None: self._default = key @@ -106,7 +105,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 +121,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: @@ -206,37 +202,30 @@ 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. """ for key, data in self._store.items(): - xSub = etree.SubElement(xParent, "entry", attrib={ + yield (data["name"], { "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 - 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 @@ -270,7 +259,7 @@ class NWStatus: return False return True - def _createIcon(self, col): + def _createIcon(self, red, green, blue): """Generate an icon for a status label. """ pixmap = QPixmap(self._iPX, self._iPX) @@ -278,7 +267,7 @@ class NWStatus: painter = QPainter(pixmap) painter.setRenderHint(QPainter.Antialiasing) - painter.fillPath(self._iconPath, QColor(*col)) + painter.fillPath(self._iconPath, QColor(red, green, blue)) painter.end() return QIcon(pixmap) diff --git a/novelwriter/core/tohtml.py b/novelwriter/core/tohtml.py index 2110300c..88647d63 100644 --- a/novelwriter/core/tohtml.py +++ b/novelwriter/core/tohtml.py @@ -315,7 +315,7 @@ class ToHtml(Tokenizer): "\n" "\n" ).format( - projTitle=self.theProject.projName, + projTitle=self.theProject.data.name, htmlStyle="\n".join(theStyle), bodyText=bodyText, ) diff --git a/novelwriter/core/tokenizer.py b/novelwriter/core/tokenizer.py index c55e3659..e81fe2a6 100644 --- a/novelwriter/core/tokenizer.py +++ b/novelwriter/core/tokenizer.py @@ -327,9 +327,10 @@ class Tokenizer(ABC): """Run trough the various replace doctionaries. """ # Process the user's auto-replace dictionary - if len(self.theProject.autoReplace) > 0: + autoReplace = self.theProject.data.autoReplace + if len(autoReplace) > 0: repDict = {} - for aKey, aVal in self.theProject.autoReplace.items(): + for aKey, aVal in autoReplace.items(): repDict[f"<{aKey}>"] = aVal xRep = re.compile("|".join([re.escape(k) for k in repDict.keys()]), flags=re.DOTALL) self._theText = xRep.sub(lambda x: repDict[x.group(0)], self._theText) diff --git a/novelwriter/core/toodt.py b/novelwriter/core/toodt.py index 022bda4c..594074ff 100644 --- a/novelwriter/core/toodt.py +++ b/novelwriter/core/toodt.py @@ -261,8 +261,8 @@ class ToOdt(Tokenizer): # =============== if self._headerText == "": - theTitle = self.theProject.bookTitle - theAuth = self.theProject.getAuthors() + theTitle = self.theProject.data.title + theAuth = self.theProject.getFormattedAuthors() self._headerText = f"{theTitle} / {theAuth} /" # Create Roots diff --git a/novelwriter/core/tree.py b/novelwriter/core/tree.py index 506556cd..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,30 +112,25 @@ 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 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() diff --git a/novelwriter/dialogs/projdetails.py b/novelwriter/dialogs/projdetails.py index 7f2259c3..0f185e89 100644 --- a/novelwriter/dialogs/projdetails.py +++ b/novelwriter/dialogs/projdetails.py @@ -157,7 +157,7 @@ class GuiProjectDetailsMain(QWidget): # Header # ====== - self.bookTitle = QLabel(self.theProject.bookTitle) + self.bookTitle = QLabel(self.theProject.data.title) bookFont = self.bookTitle.font() bookFont.setPointSizeF(2.2*fPt) bookFont.setWeight(QFont.Bold) @@ -166,7 +166,7 @@ class GuiProjectDetailsMain(QWidget): self.bookTitle.setWordWrap(True) self.projName = QLabel( - self.tr("Working Title: {0}").format(self.theProject.projName) + self.tr("Working Title: {0}").format(self.theProject.data.name) ) workFont = self.projName.font() workFont.setPointSizeF(0.8*fPt) @@ -175,7 +175,9 @@ class GuiProjectDetailsMain(QWidget): self.projName.setAlignment(Qt.AlignHCenter) self.projName.setWordWrap(True) - self.bookAuthors = QLabel(self.tr("By {0}").format(self.theProject.getAuthors())) + self.bookAuthors = QLabel(self.tr("By {0}").format( + self.theProject.getFormattedAuthors() + )) authFont = self.bookAuthors.font() authFont.setPointSizeF(1.2*fPt) self.bookAuthors.setFont(authFont) @@ -255,7 +257,7 @@ class GuiProjectDetailsMain(QWidget): self.wordCountVal.setText(f"{nwCount:n}") self.chapCountVal.setText(f"{hCounts[2]:n}") self.sceneCountVal.setText(f"{hCounts[3]:n}") - self.revCountVal.setText(f"{self.theProject.saveCount:n}") + self.revCountVal.setText(f"{self.theProject.data.saveCount:n}") self.editTimeVal.setText(f"{edTime//3600:02d}:{edTime%3600//60:02d}") self.projPathVal.setText(self.theProject.projPath) diff --git a/novelwriter/dialogs/projsettings.py b/novelwriter/dialogs/projsettings.py index eb96ffb2..77da0860 100644 --- a/novelwriter/dialogs/projsettings.py +++ b/novelwriter/dialogs/projsettings.py @@ -114,13 +114,13 @@ class GuiProjectSettings(PagedDialog): spellLang = self.tabMain.spellLang.currentData() doBackup = not self.tabMain.doBackup.isChecked() - self.theProject.setProjectName(projName) - self.theProject.setBookTitle(bookTitle) - self.theProject.setBookAuthors(bookAuthors) - self.theProject.setProjBackup(doBackup) + self.theProject.data.setName(projName) + self.theProject.data.setTitle(bookTitle) + self.theProject.data.setAuthors(bookAuthors) + self.theProject.data.setDoBackup(doBackup) # Remember this as updating spell dictionary can be expensive - self._spellChanged = self.theProject.setSpellLang(spellLang) + self._spellChanged = self.theProject.data.setSpellLang(spellLang) if self.tabStatus.colChanged: newList, delList = self.tabStatus.getNewList() @@ -135,7 +135,7 @@ class GuiProjectSettings(PagedDialog): if self.tabReplace.arChanged: newList = self.tabReplace.getNewList() - self.theProject.setAutoReplace(newList) + self.theProject.data.setAutoReplace(newList) self._saveGuiSettings() self.accept() @@ -209,7 +209,7 @@ class GuiProjectEditMain(QWidget): self.editName = QLineEdit() self.editName.setMaxLength(200) self.editName.setMaximumWidth(xW) - self.editName.setText(self.theProject.projName) + self.editName.setText(self.theProject.data.name) self.mainForm.addRow( self.tr("Project name"), self.editName, @@ -219,7 +219,7 @@ class GuiProjectEditMain(QWidget): self.editTitle = QLineEdit() self.editTitle.setMaxLength(200) self.editTitle.setMaximumWidth(xW) - self.editTitle.setText(self.theProject.bookTitle) + self.editTitle.setText(self.theProject.data.title) self.mainForm.addRow( self.tr("Novel title"), self.editTitle, @@ -229,7 +229,7 @@ class GuiProjectEditMain(QWidget): self.editAuthors = QPlainTextEdit() self.editAuthors.setMaximumHeight(xH) self.editAuthors.setMaximumWidth(xW) - self.editAuthors.setPlainText("\n".join(self.theProject.bookAuthors)) + self.editAuthors.setPlainText("\n".join(self.theProject.data.authors)) self.mainForm.addRow( self.tr("Author(s)"), self.editAuthors, @@ -252,13 +252,13 @@ class GuiProjectEditMain(QWidget): ) spellIdx = 0 - if self.theProject.projSpell is not None: - spellIdx = self.spellLang.findData(self.theProject.projSpell) + if self.theProject.data.spellLang is not None: + spellIdx = self.spellLang.findData(self.theProject.data.spellLang) if spellIdx != -1: self.spellLang.setCurrentIndex(spellIdx) self.doBackup = QSwitch(self) - self.doBackup.setChecked(not self.theProject.doBackup) + self.doBackup.setChecked(not self.theProject.data.doBackup) self.mainForm.addRow( self.tr("No backup on close"), self.doBackup, @@ -288,11 +288,11 @@ class GuiProjectEditStatus(QWidget): self.mainTheme = projGui.mainGui.mainTheme if isStatus: - self.theStatus = self.theProject.statusItems + self.theStatus = self.theProject.data.itemStatus pageLabel = self.tr("Novel File Status Levels") colSetting = "statusColW" else: - self.theStatus = self.theProject.importItems + self.theStatus = self.theProject.data.itemImport pageLabel = self.tr("Note File Importance Levels") colSetting = "importColW" @@ -578,7 +578,7 @@ class GuiProjectEditReplace(QWidget): self.listBox.setColumnWidth(self.COL_KEY, wCol0) self.listBox.setIndentation(0) - for aKey, aVal in self.theProject.autoReplace.items(): + for aKey, aVal in self.theProject.data.autoReplace.items(): newItem = QTreeWidgetItem(["<%s>" % aKey, aVal]) self.listBox.addTopLevelItem(newItem) diff --git a/novelwriter/gui/doceditor.py b/novelwriter/gui/doceditor.py index 0bde6d8c..b59829bc 100644 --- a/novelwriter/gui/doceditor.py +++ b/novelwriter/gui/doceditor.py @@ -684,10 +684,10 @@ class GuiDocEditor(QTextEdit): """Set the spell checker dictionary language, and emit the dictionary changed signal. """ - if self.theProject.projSpell is None: + if self.theProject.data.spellLang is None: theLang = self.mainConf.spellLanguage else: - theLang = self.theProject.projSpell + theLang = self.theProject.data.spellLang self.spEnchant.setLanguage(theLang, self.theProject.projDict) _, theProvider = self.spEnchant.describeDict() @@ -721,7 +721,7 @@ class GuiDocEditor(QTextEdit): self._spellCheck = theMode self.mainGui.mainMenu.setSpellCheck(theMode) - self.theProject.setSpellCheck(theMode) + self.theProject.data.setSpellCheck(theMode) self.highLight.setSpellCheck(theMode) if not self._bigDoc: self.spellCheckDocument() diff --git a/novelwriter/gui/docviewer.py b/novelwriter/gui/docviewer.py index 0437363e..808749df 100644 --- a/novelwriter/gui/docviewer.py +++ b/novelwriter/gui/docviewer.py @@ -217,7 +217,7 @@ class GuiDocViewer(QTextBrowser): self.verticalScrollBar().setValue(sPos) self._docHandle = tHandle - self.theProject.setLastViewed(tHandle) + self.theProject._data.setLastHandle(tHandle, "viewer") self.docHeader.setTitleFromHandle(self._docHandle) self.updateDocMargins() diff --git a/novelwriter/gui/mainmenu.py b/novelwriter/gui/mainmenu.py index 8a74035e..51a74bae 100644 --- a/novelwriter/gui/mainmenu.py +++ b/novelwriter/gui/mainmenu.py @@ -794,7 +794,7 @@ class GuiMainMenu(QMenuBar): # Tools > Check Spelling self.aSpellCheck = QAction(self.tr("Check Spelling"), self) self.aSpellCheck.setCheckable(True) - self.aSpellCheck.setChecked(self.theProject.spellCheck) + self.aSpellCheck.setChecked(self.theProject.data.spellCheck) self.aSpellCheck.triggered.connect(self._toggleSpellCheck) # triggered, not toggled! self.aSpellCheck.setShortcut("Ctrl+F7") self.toolsMenu.addAction(self.aSpellCheck) diff --git a/novelwriter/gui/noveltree.py b/novelwriter/gui/noveltree.py index 210626b5..64662e17 100644 --- a/novelwriter/gui/noveltree.py +++ b/novelwriter/gui/noveltree.py @@ -109,7 +109,7 @@ class GuiNovelView(QWidget): def refreshTree(self): """Refresh the current tree. """ - self.novelTree.refreshTree(rootHandle=self.theProject.lastNovel) + self.novelTree.refreshTree(rootHandle=self.theProject.data.getLastHandle("novelTree")) return def clearProject(self): @@ -122,7 +122,7 @@ class GuiNovelView(QWidget): def openProjectTasks(self): """Run open project tasks. """ - lastNovel = self.theProject.lastNovel + lastNovel = self.theProject.data.getLastHandle("novelTree") if lastNovel not in self.theProject.tree: lastNovel = self.theProject.tree.findRoot(nwItemClass.NOVEL) @@ -319,7 +319,7 @@ class GuiNovelToolBar(QWidget): def _refreshNovelTree(self): """Rebuild the current tree. """ - rootHandle = self.theProject.lastNovel + rootHandle = self.theProject.data.getLastHandle("novelTree") self.novelView.novelTree.refreshTree(rootHandle=rootHandle, overRide=True) return @@ -485,7 +485,7 @@ class GuiNovelTree(QTreeWidget): titleKey = selItem[0].data(self.C_TITLE, self.D_KEY) self._populateTree(rootHandle) - self.theProject.setLastNovelViewed(rootHandle) + self.theProject.data.setLastHandle(rootHandle, "novelTree") if titleKey is not None and titleKey in self._treeMap: self._treeMap[titleKey].setSelected(True) @@ -523,7 +523,8 @@ class GuiNovelTree(QTreeWidget): self._lastCol = colType self.setColumnHidden(self.C_EXTRA, colType == NovelTreeColumn.HIDDEN) if doRefresh: - self.refreshTree(rootHandle=self.theProject.lastNovel, overRide=True) + lastNovel = self.theProject.data.getLastHandle("novelTree") + self.refreshTree(rootHandle=lastNovel, overRide=True) return def setActiveHandle(self, tHandle): diff --git a/novelwriter/gui/outline.py b/novelwriter/gui/outline.py index b5a1060a..5d66d139 100644 --- a/novelwriter/gui/outline.py +++ b/novelwriter/gui/outline.py @@ -114,7 +114,7 @@ class GuiOutlineView(QWidget): def refreshTree(self): """Refresh the current tree. """ - self.outlineTree.refreshTree(rootHandle=self.theProject.lastOutline) + self.outlineTree.refreshTree(rootHandle=self.theProject.data.getLastHandle("outline")) return def clearProject(self): @@ -126,7 +126,7 @@ class GuiOutlineView(QWidget): def openProjectTasks(self): """Run open project tasks. """ - lastOutline = self.theProject.lastOutline + lastOutline = self.theProject.data.getLastHandle("outline") if not (lastOutline in self.theProject.tree or lastOutline is None): lastOutline = self.theProject.tree.findRoot(nwItemClass.NOVEL) @@ -504,7 +504,7 @@ class GuiOutlineTree(QTreeWidget): return self._populateTree(rootHandle) - self.theProject.setLastOutlineViewed(rootHandle or None) + self.theProject.data.setLastHandle(rootHandle or None, "outline") return diff --git a/novelwriter/gui/projtree.py b/novelwriter/gui/projtree.py index 02e1352a..b41e7ed7 100644 --- a/novelwriter/gui/projtree.py +++ b/novelwriter/gui/projtree.py @@ -1208,7 +1208,7 @@ class GuiProjectTree(QTreeWidget): checkMark = f" ({nwUnicode.U_CHECK})" if tItem.isNovelLike(): mStatus = ctxMenu.addMenu(self.tr("Set Status to ...")) - for n, (key, entry) in enumerate(self.theProject.statusItems.items()): + for n, (key, entry) in enumerate(self.theProject.data.itemStatus.items()): entryName = entry["name"] + (checkMark if tItem.itemStatus == key else "") aStatus = mStatus.addAction(entry["icon"], entryName) aStatus.triggered.connect( @@ -1221,7 +1221,7 @@ class GuiProjectTree(QTreeWidget): ) else: mImport = ctxMenu.addMenu(self.tr("Set Importance to ...")) - for n, (key, entry) in enumerate(self.theProject.importItems.items()): + for n, (key, entry) in enumerate(self.theProject.data.itemImport.items()): entryName = entry["name"] + (checkMark if tItem.itemImport == key else "") aImport = mImport.addAction(entry["icon"], entryName) aImport.triggered.connect( diff --git a/novelwriter/guimain.py b/novelwriter/guimain.py index 89e4ca44..f984d5d2 100644 --- a/novelwriter/guimain.py +++ b/novelwriter/guimain.py @@ -197,6 +197,8 @@ class GuiMain(QMainWindow): # Connect Signals # =============== + self.theProject.projectStatusChanged.connect(self.mainStatus.doUpdateProjectStatus) + self.viewsBar.viewChangeRequested.connect(self._changeView) self.projView.selectedItemChanged.connect(self.itemDetails.updateViewBox) @@ -383,7 +385,7 @@ class GuiMain(QMainWindow): self.mainStatus.setDocumentStatus(nwState.NONE) self.mainStatus.setStatus(self.tr("New project created ...")) - self._updateWindowTitle(self.theProject.projName) + self._updateWindowTitle(self.theProject.data.name) else: self.theProject.clearProject() @@ -417,7 +419,7 @@ class GuiMain(QMainWindow): if self.theProject.projAltered: saveOK = self.saveProject() doBackup = False - if self.theProject.doBackup and self.mainConf.backupOnClose: + if self.theProject.data.doBackup and self.mainConf.backupOnClose: doBackup = True if self.mainConf.askBeforeBackup: msgYes = self.askQuestion( @@ -521,10 +523,10 @@ class GuiMain(QMainWindow): self.theProject.index.loadIndex() # Update GUI - self._updateWindowTitle(self.theProject.projName) + self._updateWindowTitle(self.theProject.data.name) self.rebuildTrees() self.docEditor.setDictionaries() - self.docEditor.toggleSpellCheck(self.theProject.spellCheck) + self.docEditor.toggleSpellCheck(self.theProject.data.spellCheck) self.mainStatus.setRefTime(self.theProject.projOpened) self.projView.openProjectTasks() self.novelView.openProjectTasks() @@ -532,11 +534,13 @@ class GuiMain(QMainWindow): self._updateStatusWordCount() # Restore previously open documents, if any - if self.theProject.lastEdited is not None: - self.openDocument(self.theProject.lastEdited, doScroll=True) + lastEdited = self.theProject.data.getLastHandle("editor") + if lastEdited is not None: + self.openDocument(lastEdited, doScroll=True) - if self.theProject.lastViewed is not None: - self.viewDocument(self.theProject.lastViewed) + lastViewed = self.theProject.data.getLastHandle("viewer") + if lastViewed is not None: + self.viewDocument(lastViewed) # Check if we need to rebuild the index if self.theProject.index.indexBroken: @@ -607,7 +611,7 @@ class GuiMain(QMainWindow): if self.docEditor.loadText(tHandle, tLine): if changeFocus: self.docEditor.setFocus() - self.theProject.setLastEdited(tHandle) + self.theProject.data.setLastHandle(tHandle, "editor") self.projView.setSelectedHandle(tHandle, doScroll=doScroll) self.novelView.setActiveHandle(tHandle) else: @@ -676,7 +680,7 @@ class GuiMain(QMainWindow): tHandle = self.projView.getSelectedHandle() if tHandle is None: - tHandle = self.theProject.lastViewed + tHandle = self.theProject.data.getLastHandle("viewer") if tHandle is None: logger.debug("No document to view, giving up") @@ -960,7 +964,7 @@ class GuiMain(QMainWindow): if dlgProj.spellChanged: self.docEditor.setDictionaries() self.itemDetails.refreshDetails() - self._updateWindowTitle(self.theProject.projName) + self._updateWindowTitle(self.theProject.data.name) return True @@ -1223,14 +1227,14 @@ class GuiMain(QMainWindow): """Close the document edit panel. This does not hide the editor. """ self.closeDocument() - self.theProject.setLastEdited(None) + self.theProject.data.setLastHandle(None, "editor") return def closeDocViewer(self): """Close the document view panel. """ self.docViewer.clearViewer() - self.theProject.setLastViewed(None) + self.theProject.data.setLastHandle(None, "viewer") bPos = self.splitMain.sizes() self.splitView.setVisible(False) self.splitDocs.setSizes([bPos[1], 0]) @@ -1555,13 +1559,13 @@ class GuiMain(QMainWindow): self.theProject.updateWordCounts() if self.mainConf.incNotesWCount: - currWords = self.theProject.currWCount - diffWords = currWords - self.theProject.lastWCount + iTotal = sum(self.theProject.data.initCounts) + cTotal = sum(self.theProject.data.currCounts) + self.mainStatus.setProjectStats(cTotal, cTotal - iTotal) else: - currWords = self.theProject.currNovelWC - diffWords = currWords - self.theProject.lastNovelWC - - self.mainStatus.setProjectStats(currWords, diffWords) + iNovel, _ = self.theProject.data.initCounts + cNovel, _ = self.theProject.data.currCounts + self.mainStatus.setProjectStats(cNovel, cNovel - iNovel) return diff --git a/novelwriter/tools/build.py b/novelwriter/tools/build.py index 683f8e99..8278fb95 100644 --- a/novelwriter/tools/build.py +++ b/novelwriter/tools/build.py @@ -126,7 +126,7 @@ class GuiBuildNovel(QDialog): self.fmtTitle.setMinimumWidth(xFmt) self.fmtTitle.setToolTip(fmtHelp) self.fmtTitle.setText( - self._reFmtCodes(self.theProject.titleFormat["title"]) + self._reFmtCodes(self.theProject.data.getTitleFormat("title")) ) self.fmtChapter = QLineEdit() @@ -134,7 +134,7 @@ class GuiBuildNovel(QDialog): self.fmtChapter.setMinimumWidth(xFmt) self.fmtChapter.setToolTip(fmtHelp) self.fmtChapter.setText( - self._reFmtCodes(self.theProject.titleFormat["chapter"]) + self._reFmtCodes(self.theProject.data.getTitleFormat("chapter")) ) self.fmtUnnumbered = QLineEdit() @@ -142,7 +142,7 @@ class GuiBuildNovel(QDialog): self.fmtUnnumbered.setMinimumWidth(xFmt) self.fmtUnnumbered.setToolTip(fmtHelp) self.fmtUnnumbered.setText( - self._reFmtCodes(self.theProject.titleFormat["unnumbered"]) + self._reFmtCodes(self.theProject.data.getTitleFormat("unnumbered")) ) self.fmtScene = QLineEdit() @@ -150,7 +150,7 @@ class GuiBuildNovel(QDialog): self.fmtScene.setMinimumWidth(xFmt) self.fmtScene.setToolTip(fmtHelp + fmtScHelp) self.fmtScene.setText( - self._reFmtCodes(self.theProject.titleFormat["scene"]) + self._reFmtCodes(self.theProject.data.getTitleFormat("scene")) ) self.fmtSection = QLineEdit() @@ -158,7 +158,7 @@ class GuiBuildNovel(QDialog): self.fmtSection.setMinimumWidth(xFmt) self.fmtSection.setToolTip(fmtHelp + fmtScHelp) self.fmtSection.setText( - self._reFmtCodes(self.theProject.titleFormat["section"]) + self._reFmtCodes(self.theProject.data.getTitleFormat("section")) ) self.buildLang = QComboBox() @@ -168,7 +168,7 @@ class GuiBuildNovel(QDialog): for langID, langName in theLangs: self.buildLang.addItem(langName, langID) - langIdx = self.buildLang.findData(self.theProject.projLang) + langIdx = self.buildLang.findData(self.theProject.data.language) if langIdx != -1: self.buildLang.setCurrentIndex(langIdx) @@ -888,7 +888,7 @@ class GuiBuildNovel(QDialog): # Generate File Name # ================== - cleanName = makeFileNameSafe(self.theProject.projName) + cleanName = makeFileNameSafe(self.theProject.data.name) fileName = "%s.%s" % (cleanName, fileExt) saveDir = self.mainConf.lastPath if not os.path.isdir(saveDir): @@ -972,9 +972,9 @@ class GuiBuildNovel(QDialog): elif theFmt == self.FMT_JSON_H or theFmt == self.FMT_JSON_M: jsonData = { "meta": { - "workingTitle": self.theProject.projName, - "novelTitle": self.theProject.bookTitle, - "authors": self.theProject.bookAuthors, + "workingTitle": self.theProject.data.name, + "novelTitle": self.theProject.data.title, + "authors": self.theProject.data.authors, "buildTime": self.buildTime, } } @@ -1159,7 +1159,7 @@ class GuiBuildNovel(QDialog): logger.debug("Saving GuiBuildNovel settings") # Formatting - self.theProject.setTitleFormat({ + self.theProject.data.setTitleFormat({ "title": self.fmtTitle.text().strip(), "chapter": self.fmtChapter.text().strip(), "unnumbered": self.fmtUnnumbered.text().strip(), diff --git a/sample/nwProject.nwx b/sample/nwProject.nwx index 535b6d18..1dba26ca 100644 --- a/sample/nwProject.nwx +++ b/sample/nwProject.nwx @@ -1,37 +1,38 @@ - + Sample Project Sample Project Jane Smith Jay Doh - 1386 + 1409 236 - 69352 + 69427 False en_GB True None - 636b6aa9b697b - 636b6aa9b697b - 7031beac91f75 - 7031beac91f75 - 1363 954 409 + + 636b6aa9b697b + 636b6aa9b697b + 7031beac91f75 + 7031beac91f75 + B E D - %title% - Chapter %chw%: %title% - %title% - Scene %ch%.%sc%: %title% -
+ %title% + Chapter %chw%: %title% + %title% + Scene %ch%.%sc%: %title% +
New @@ -55,43 +56,43 @@ Novel - + Title Page - + Page - + Part One - + Chapter One - + Making a Scene - + Another Scene - + Interlude - + A Note on Structure - + Chapter Two - + We Found John! @@ -99,11 +100,11 @@ Sequel - + Title Page - + Chapter One @@ -115,11 +116,11 @@ Main Characters - + John Smith - + Jane Smith @@ -127,15 +128,15 @@ Locations - + Earth - + Space - + Mars @@ -147,7 +148,7 @@ Scenes - + Old File @@ -155,7 +156,7 @@ Trash - + Delete Me!
diff --git a/tests/conftest.py b/tests/conftest.py index 12842add..50955a31 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -214,27 +214,6 @@ def mockRnd(monkeypatch): # Temp Project Folders ## -@pytest.fixture(scope="function") -def nwMinimal(tmpDir): - """A minimal novelWriter example project. - """ - tstDir = os.path.dirname(__file__) - srcDir = os.path.join(tstDir, "minimal") - dstDir = os.path.join(tmpDir, "minimal") - if os.path.isdir(dstDir): - shutil.rmtree(dstDir) - - shutil.copytree(srcDir, dstDir) - cleanProject(dstDir) - - yield dstDir - - if os.path.isdir(dstDir): - shutil.rmtree(dstDir) - - return - - @pytest.fixture(scope="function") def nwLipsum(tmpDir): """A medium sized novelWriter example project with a lot of Lorem diff --git a/tests/files/nwProject-1.0.nwx b/tests/files/nwProject-1.0.nwx new file mode 100644 index 00000000..6d1b1990 --- /dev/null +++ b/tests/files/nwProject-1.0.nwx @@ -0,0 +1,299 @@ + + + + Sample Project + Sample Project + Jane Smith + Jay Doh + False + + + True + True + 636b6aa9b697b + 636b6aa9b697b + 914 + + B + E + D + + + %title% + Chapter %ch%: %title% + %title% + Scene %ch%.%sc%: %title% +
+ True + True + False +
+ + New + Notes + Started + 1st Draft + 2nd Draft + 3rd Draft + Finished + + + None + Minor + Major + Main + +
+ + + Novel + ROOT + NOVEL + Started + True + + + Title Page + FILE + NOVEL + Started + False + True + TITLE + 72 + 15 + 2 + 78 + + + Page + FILE + NOVEL + New + False + True + PAGE + 208 + 40 + 2 + 213 + + + Part One + FILE + NOVEL + New + False + True + PARTITION + 23 + 5 + 1 + 0 + + + A Folder + FOLDER + NOVEL + 1st Draft + True + + + Chapter One + FILE + NOVEL + Notes + False + True + CHAPTER + 12 + 3 + 0 + 215 + + + Making a Scene + FILE + NOVEL + 1st Draft + False + True + SCENE + 1199 + 216 + 7 + 527 + + + Another Scene + FILE + NOVEL + 1st Draft + False + True + SCENE + 476 + 93 + 3 + 551 + + + Interlude + FILE + NOVEL + Finished + False + True + UNNUMBERED + 633 + 101 + 3 + 1238 + + + A Note on Structure + FILE + NOVEL + 2nd Draft + False + False + NOTE + 1692 + 313 + 6 + 1721 + + + Chapter Two + FILE + NOVEL + 1st Draft + False + True + CHAPTER + 139 + 28 + 1 + 343 + + + We Found John! + FILE + NOVEL + 1st Draft + False + True + SCENE + 189 + 37 + 1 + 224 + + + Characters + ROOT + CHARACTER + None + True + + + Main Characters + FOLDER + CHARACTER + None + True + + + John Smith + FILE + CHARACTER + Minor + False + True + NOTE + 49 + 9 + 1 + 24 + + + Jane Smith + FILE + CHARACTER + Major + False + True + NOTE + 55 + 9 + 1 + 25 + + + Locations + ROOT + WORLD + None + True + + + Earth + FILE + WORLD + Main + False + True + NOTE + 76 + 15 + 1 + 20 + + + Space + FILE + WORLD + Minor + False + True + NOTE + 115 + 24 + 1 + 133 + + + Mars + FILE + WORLD + Major + False + True + NOTE + 28 + 6 + 1 + 45 + + + Trash + TRASH + TRASH + None + True + + + Delete Me! + FILE + NOVEL + New + False + True + SCENE + 0 + 0 + 0 + 36 + + +
diff --git a/tests/files/nwProject-1.1.nwx b/tests/files/nwProject-1.1.nwx new file mode 100644 index 00000000..d31ab3ff --- /dev/null +++ b/tests/files/nwProject-1.1.nwx @@ -0,0 +1,283 @@ + + + + Sample Project + Sample Project + Jane Smith + Jay Doh + 5 + 10 + 1000 + + + True + True + True + 636b6aa9b697b + bb2c23b3c42cc + 967 + + B + E + D + + + %title% + Chapter %ch%: %title% + %title% + Scene %ch%.%sc%: %title% +
+
+ + New + Notes + Started + 1st Draft + 2nd Draft + 3rd Draft + Finished + + + None + Minor + Major + Main + +
+ + + Novel + ROOT + NOVEL + Started + True + + + Title Page + FILE + NOVEL + Started + True + TITLE + 72 + 15 + 2 + 78 + + + Page + FILE + NOVEL + New + True + PAGE + 210 + 40 + 2 + 213 + + + Part One + FILE + NOVEL + New + True + PARTITION + 23 + 5 + 1 + 0 + + + A Folder + FOLDER + NOVEL + 1st Draft + True + + + Chapter One + FILE + NOVEL + Notes + True + CHAPTER + 12 + 3 + 0 + 215 + + + Making a Scene + FILE + NOVEL + 1st Draft + True + SCENE + 1483 + 263 + 8 + 1086 + + + Another Scene + FILE + NOVEL + 1st Draft + True + SCENE + 476 + 93 + 3 + 428 + + + Interlude + FILE + NOVEL + Finished + True + UNNUMBERED + 633 + 101 + 3 + 1238 + + + A Note on Structure + FILE + NOVEL + 2nd Draft + False + NOTE + 1692 + 313 + 6 + 1721 + + + Chapter Two + FILE + NOVEL + 1st Draft + True + CHAPTER + 139 + 28 + 1 + 343 + + + We Found John! + FILE + NOVEL + 1st Draft + True + SCENE + 189 + 37 + 1 + 224 + + + Characters + ROOT + CHARACTER + None + True + + + Main Characters + FOLDER + CHARACTER + None + True + + + John Smith + FILE + CHARACTER + Minor + True + NOTE + 49 + 9 + 1 + 24 + + + Jane Smith + FILE + CHARACTER + Major + True + NOTE + 55 + 9 + 1 + 25 + + + Locations + ROOT + WORLD + None + True + + + Earth + FILE + WORLD + Main + True + NOTE + 76 + 15 + 1 + 20 + + + Space + FILE + WORLD + Minor + True + NOTE + 115 + 24 + 1 + 133 + + + Mars + FILE + WORLD + Major + True + NOTE + 28 + 6 + 1 + 45 + + + Trash + TRASH + TRASH + None + True + + + Delete Me! + FILE + NOVEL + New + True + SCENE + 30 + 6 + 1 + 36 + + +
diff --git a/tests/files/nwProject-1.2.nwx b/tests/files/nwProject-1.2.nwx new file mode 100644 index 00000000..e511afc9 --- /dev/null +++ b/tests/files/nwProject-1.2.nwx @@ -0,0 +1,313 @@ + + + + Sample Project + Sample Project + Jane Smith + Jay Doh + 5 + 10 + 1000 + + + True + en_GB + True + en_GB + True + 636b6aa9b697b + 636b6aa9b697b + 1216 + 840 + 376 + + B + E + D + + + %title% + Chapter %chw%: %title% + %title% + Scene %ch%.%sc%: %title% +
+
+ + New + Notes + Started + 1st Draft + 2nd Draft + 3rd Draft + Finished + + + None + Minor + Major + Main + +
+ + + Novel + ROOT + NOVEL + Started + True + + + Title Page + FILE + NOVEL + Started + True + TITLE + 241 + 42 + 3 + 252 + + + Page + FILE + NOVEL + New + True + PAGE + 125 + 26 + 2 + 127 + + + Part One + FILE + NOVEL + New + True + PARTITION + 26 + 6 + 1 + 30 + + + A Folder + FOLDER + NOVEL + 1st Draft + True + + + Chapter One + FILE + NOVEL + Notes + True + CHAPTER + 75 + 14 + 1 + 279 + + + Making a Scene + FILE + NOVEL + 1st Draft + True + SCENE + 2429 + 432 + 14 + 61 + + + Another Scene + FILE + NOVEL + 1st Draft + True + SCENE + 476 + 93 + 3 + 577 + + + Interlude + FILE + NOVEL + New + True + UNNUMBERED + 617 + 101 + 3 + 1137 + + + A Note on Structure + FILE + NOVEL + 2nd Draft + False + NOTE + 1692 + 313 + 6 + 1110 + + + Chapter Two + FILE + NOVEL + 1st Draft + True + CHAPTER + 139 + 28 + 1 + 343 + + + We Found John! + FILE + NOVEL + 1st Draft + True + SCENE + 189 + 37 + 1 + 224 + + + Characters + ROOT + CHARACTER + None + True + + + Main Characters + FOLDER + CHARACTER + None + True + + + John Smith + FILE + CHARACTER + Minor + True + NOTE + 49 + 9 + 1 + 24 + + + Jane Smith + FILE + CHARACTER + Major + True + NOTE + 55 + 9 + 1 + 25 + + + Locations + ROOT + WORLD + None + True + + + Earth + FILE + WORLD + Main + True + NOTE + 76 + 15 + 1 + 20 + + + Space + FILE + WORLD + Minor + True + NOTE + 115 + 24 + 1 + 133 + + + Mars + FILE + WORLD + Major + True + NOTE + 28 + 6 + 1 + 45 + + + Outtakes + ROOT + ARCHIVE + None + True + + + Scenes + FOLDER + ARCHIVE + None + True + + + Old File + FILE + NOVEL + 1st Draft + True + SCENE + 315 + 55 + 1 + 322 + + + Trash + TRASH + TRASH + None + True + + + Delete Me! + FILE + NOVEL + New + True + SCENE + 30 + 6 + 1 + 36 + + +
diff --git a/tests/files/nwProject-1.3.nwx b/tests/files/nwProject-1.3.nwx new file mode 100644 index 00000000..6c1d3a2e --- /dev/null +++ b/tests/files/nwProject-1.3.nwx @@ -0,0 +1,313 @@ + + + + Sample Project + Sample Project + Jane Smith + Jay Doh + 5 + 10 + 1000 + + + True + en_GB + True + en_GB + True + 636b6aa9b697b + 636b6aa9b697b + 1206 + 830 + 376 + + B + E + D + + + %title% + Chapter %chw%: %title% + %title% + Scene %ch%.%sc%: %title% +
+
+ + New + Notes + Started + 1st Draft + 2nd Draft + 3rd Draft + Finished + + + None + Minor + Major + Main + +
+ + + Novel + ROOT + NOVEL + Started + True + + + Title Page + FILE + NOVEL + Started + True + DOCUMENT + 93 + 19 + 2 + 2 + + + Page + FILE + NOVEL + New + True + DOCUMENT + 186 + 39 + 2 + 212 + + + Part One + FILE + NOVEL + New + True + DOCUMENT + 26 + 6 + 1 + 33 + + + A Folder + FOLDER + NOVEL + 1st Draft + True + + + Chapter One + FILE + NOVEL + Notes + True + DOCUMENT + 75 + 14 + 1 + 279 + + + Making a Scene + FILE + NOVEL + 1st Draft + True + DOCUMENT + 2429 + 432 + 14 + 62 + + + Another Scene + FILE + NOVEL + 1st Draft + True + DOCUMENT + 476 + 93 + 3 + 577 + + + Interlude + FILE + NOVEL + New + True + DOCUMENT + 617 + 101 + 3 + 4 + + + A Note on Structure + FILE + NOVEL + 2nd Draft + False + NOTE + 1692 + 313 + 6 + 1110 + + + Chapter Two + FILE + NOVEL + 1st Draft + True + DOCUMENT + 139 + 28 + 1 + 343 + + + We Found John! + FILE + NOVEL + 1st Draft + True + DOCUMENT + 189 + 37 + 1 + 224 + + + Characters + ROOT + CHARACTER + None + True + + + Main Characters + FOLDER + CHARACTER + None + True + + + John Smith + FILE + CHARACTER + Minor + True + NOTE + 49 + 9 + 1 + 24 + + + Jane Smith + FILE + CHARACTER + Major + True + NOTE + 55 + 9 + 1 + 25 + + + Locations + ROOT + WORLD + None + True + + + Earth + FILE + WORLD + Main + True + NOTE + 76 + 15 + 1 + 20 + + + Space + FILE + WORLD + Minor + True + NOTE + 115 + 24 + 1 + 133 + + + Mars + FILE + WORLD + Major + True + NOTE + 28 + 6 + 1 + 45 + + + Archive + ROOT + ARCHIVE + New + True + + + Scenes + FOLDER + ARCHIVE + New + True + + + Old File + FILE + NOVEL + 1st Draft + True + DOCUMENT + 314 + 55 + 1 + 322 + + + Trash + TRASH + TRASH + None + True + + + Delete Me! + FILE + NOVEL + New + True + DOCUMENT + 30 + 6 + 1 + 36 + + +
diff --git a/tests/files/nwProject-1.4.nwx b/tests/files/nwProject-1.4.nwx new file mode 100644 index 00000000..706b2266 --- /dev/null +++ b/tests/files/nwProject-1.4.nwx @@ -0,0 +1,163 @@ + + + + Sample Project + Sample Project + Jane Smith + Jay Doh + 5 + 10 + 1000 + + + True + en_GB + True + en_GB + 954 + 409 + + 636b6aa9b697b + 636b6aa9b697b + 7031beac91f75 + 7031beac91f75 + + + B + E + D + + + %title% + Chapter %chw%: %title% + %title% + Scene %ch%.%sc%: %title% + + + + New + Notes + Started + 1st Draft + 2nd Draft + 3rd Draft + Finished + + + None + Minor + Major + Main + + + + + + Novel + + + + Title Page + + + + Page + + + + Part One + + + + Chapter One + + + + Making a Scene + + + + Another Scene + + + + Interlude + + + + A Note on Structure + + + + Chapter Two + + + + We Found John! + + + + Sequel + + + + Title Page + + + + Chapter One + + + + Characters + + + + Main Characters + + + + John Smith + + + + Jane Smith + + + + Locations + + + + Earth + + + + Space + + + + Mars + + + + Archive + + + + Scenes + + + + Old File + + + + Trash + + + + Delete Me! + + + diff --git a/tests/lipsum/nwProject.nwx b/tests/lipsum/nwProject.nwx index bfaedd84..5ac1b4de 100644 --- a/tests/lipsum/nwProject.nwx +++ b/tests/lipsum/nwProject.nwx @@ -1,35 +1,37 @@ - + Lorem Ipsum Lorem Ipsum lipsum.com - 28 + 32 24 - 1874 + 1889 False en_GB False None - 7a992350f3eb6 - None - None - None - 3847 + 3847 3109 738 + + 7a992350f3eb6 + None + b3643d0f92e32 + None + Replace Text 1 Replace Text 2 - %title% - Chapter %ch%: %title% - %title% - * * * -
+ %title% + Chapter %ch%: %title% + %title% + * * * +
New @@ -50,19 +52,19 @@ Novel - + Lorem Ipsum - + Front Matter - + Prologue - + Act One @@ -70,19 +72,19 @@ Chapter One - + Chapter One - + Scene One - + Scene Two - + Interlude @@ -90,19 +92,19 @@ Chapter Two - + Chapter Two - + Scene Three - + Scene Four - + Scene Five @@ -110,7 +112,7 @@ Characters - + Mr. Nobody @@ -118,7 +120,7 @@ Plot - + Main @@ -126,7 +128,7 @@ World - + Ancient Europe
diff --git a/tests/minimal/content/8c659a11cd429.nwd b/tests/minimal/content/8c659a11cd429.nwd deleted file mode 100644 index 2d7072eb..00000000 --- a/tests/minimal/content/8c659a11cd429.nwd +++ /dev/null @@ -1,5 +0,0 @@ -%%~name: New Scene -%%~path: a6d311a93600a/8c659a11cd429 -%%~kind: NOVEL/DOCUMENT -### New Scene - diff --git a/tests/minimal/content/a35baf2e93843.nwd b/tests/minimal/content/a35baf2e93843.nwd deleted file mode 100644 index b328746c..00000000 --- a/tests/minimal/content/a35baf2e93843.nwd +++ /dev/null @@ -1,6 +0,0 @@ -%%~name: Title Page -%%~path: a508bb932959c/a35baf2e93843 -%%~kind: NOVEL/DOCUMENT -#! Minimal - ->> By Jane Doe, John Doh << diff --git a/tests/minimal/content/f5ab3e30151e1.nwd b/tests/minimal/content/f5ab3e30151e1.nwd deleted file mode 100644 index b4bbf9c6..00000000 --- a/tests/minimal/content/f5ab3e30151e1.nwd +++ /dev/null @@ -1,5 +0,0 @@ -%%~name: New Chapter -%%~path: a6d311a93600a/f5ab3e30151e1 -%%~kind: NOVEL/DOCUMENT -## New Chapter - diff --git a/tests/minimal/nwProject.nwx b/tests/minimal/nwProject.nwx deleted file mode 100644 index b9180838..00000000 --- a/tests/minimal/nwProject.nwx +++ /dev/null @@ -1,79 +0,0 @@ - - - - Test Minimal - Minimal - Jane Doe - John Doh - 19 - 2 - 167 - - - True - en_GB - False - None - None - None - a508bb932959c - None - 10 - 10 - 0 - - - %title% - Chapter %ch%: %title% - %title% - * * * -
-
- - New - Note - Draft - Finished - - - New - Minor - Major - Main - -
- - - - Novel - - - - Title Page - - - - New Chapter - - - - New Chapter - - - - New Scene - - - - Plot - - - - Characters - - - - World - - -
diff --git a/tests/mock.py b/tests/mock.py index ff67f4bf..74623dd1 100644 --- a/tests/mock.py +++ b/tests/mock.py @@ -19,14 +19,18 @@ You should have received a copy of the GNU General Public License along with this program. If not, see . """ +from PyQt5.QtCore import QObject + # =========================================================================== # # Mock GUI # =========================================================================== # -class MockGuiMain: +class MockGuiMain(QObject): def __init__(self): + super().__init__() + self.mainConf = None self.hasProject = True self.theProject = None diff --git a/tests/reference/coreProject_NewCustomA_nwProject.nwx b/tests/reference/coreProject_NewCustomA_nwProject.nwx index ef2056f7..76509948 100644 --- a/tests/reference/coreProject_NewCustomA_nwProject.nwx +++ b/tests/reference/coreProject_NewCustomA_nwProject.nwx @@ -1,5 +1,5 @@ - + Test Custom Test Novel @@ -14,122 +14,123 @@ None False None - None - None - None - None - 0 0 0 + + None + None + None + None + - %title% - %title% - %title% - * * * -
+ %title% + %title% + %title% + * * * +
- New - Note - Draft - Finished + New + Note + Draft + Finished - New - Minor - Major - Main + New + Minor + Major + Main
- + - Novel + Novel - - - Title Page + + + Title Page - - - Chapter 1 + + + Chapter 1 - - - Scene 1.1 + + + Scene 1.1 - - - Scene 1.2 + + + Scene 1.2 - - - Scene 1.3 + + + Scene 1.3 - - - Chapter 2 + + + Chapter 2 - - - Scene 2.1 + + + Scene 2.1 - - - Scene 2.2 + + + Scene 2.2 - - - Scene 2.3 + + + Scene 2.3 - - - Chapter 3 + + + Chapter 3 - - - Scene 3.1 + + + Scene 3.1 - - - Scene 3.2 + + + Scene 3.2 - - - Scene 3.3 + + + Scene 3.3 - + - Plot + Plot - - - Main Plot + + + Main Plot - + - Characters + Characters - - - Protagonist + + + Protagonist - + - Locations + Locations - - - Main Location + + + Main Location - + - Archive + Archive - + - Trash + Trash diff --git a/tests/reference/coreProject_NewCustomB_nwProject.nwx b/tests/reference/coreProject_NewCustomB_nwProject.nwx index 30d6c2a9..afd7627c 100644 --- a/tests/reference/coreProject_NewCustomB_nwProject.nwx +++ b/tests/reference/coreProject_NewCustomB_nwProject.nwx @@ -1,5 +1,5 @@ - + Test Custom Test Novel @@ -14,98 +14,99 @@ None False None - None - None - None - None - 0 0 0 + + None + None + None + None + - %title% - %title% - %title% - * * * -
+ %title% + %title% + %title% + * * * +
- New - Note - Draft - Finished + New + Note + Draft + Finished - New - Minor - Major - Main + New + Minor + Major + Main - + - Novel + Novel - - - Title Page + + + Title Page - - - Scene 1 + + + Scene 1 - - - Scene 2 + + + Scene 2 - - - Scene 3 + + + Scene 3 - - - Scene 4 + + + Scene 4 - - - Scene 5 + + + Scene 5 - - - Scene 6 + + + Scene 6 - + - Plot + Plot - - - Main Plot + + + Main Plot - + - Characters + Characters - - - Protagonist + + + Protagonist - + - Locations + Locations - - - Main Location + + + Main Location - + - Archive + Archive - + - Trash + Trash
diff --git a/tests/reference/coreProject_NewFileFolder_nwProject.nwx b/tests/reference/coreProject_NewFileFolder_nwProject.nwx index e1faf5a7..b1370820 100644 --- a/tests/reference/coreProject_NewFileFolder_nwProject.nwx +++ b/tests/reference/coreProject_NewFileFolder_nwProject.nwx @@ -1,5 +1,5 @@ - + New Project New Novel @@ -13,20 +13,21 @@ None False None - None - None - None - None - 13 10 3 + + None + None + None + None + - %title% - %title% - %title% - * * * -
+ %title% + %title% + %title% + * * * +
New @@ -59,7 +60,7 @@ World - + Title Page @@ -67,23 +68,23 @@ New Chapter - + New Chapter - + New Scene - + Stuff - - + + Hello - - + + Jane diff --git a/tests/reference/coreProject_NewMinimal_nwProject.nwx b/tests/reference/coreProject_NewMinimal_nwProject.nwx index 282aa1f3..d43aee14 100644 --- a/tests/reference/coreProject_NewMinimal_nwProject.nwx +++ b/tests/reference/coreProject_NewMinimal_nwProject.nwx @@ -1,8 +1,8 @@ - + New Project - + None 2 1 0 @@ -12,66 +12,67 @@ None False None - None - None - None - None - 0 0 0 + + None + None + None + None + - %title% - %title% - %title% - * * * -
+ %title% + %title% + %title% + * * * +
- New - Note - Draft - Finished + New + Note + Draft + Finished - New - Minor - Major - Main + New + Minor + Major + Main - + - Novel + Novel - - - Title Page + + + Title Page - - - New Chapter + + + New Chapter - - - New Scene + + + New Scene - + - Plot + Plot - + - Characters + Characters - + - Locations + Locations - + - Archive + Archive
diff --git a/tests/reference/coreProject_NewRoot_nwProject.nwx b/tests/reference/coreProject_NewRoot_nwProject.nwx index af7e0b73..ed50e9ca 100644 --- a/tests/reference/coreProject_NewRoot_nwProject.nwx +++ b/tests/reference/coreProject_NewRoot_nwProject.nwx @@ -1,5 +1,5 @@ - + New Project New Novel @@ -13,20 +13,21 @@ None False None - None - None - None - None - 9 9 0 + + None + None + None + None + - %title% - %title% - %title% - * * * -
+ %title% + %title% + %title% + * * * +
New @@ -59,7 +60,7 @@ World
- + Title Page @@ -67,42 +68,42 @@ New Chapter - + New Chapter - + New Scene - + Novel - + Plot - + Characters - + Locations - + Timeline - + Objects - + Custom - + Custom diff --git a/tests/reference/guiEditor_Main_Final_0000000000020.nwd b/tests/reference/guiEditor_Main_Final_0000000000010.nwd similarity index 71% rename from tests/reference/guiEditor_Main_Final_0000000000020.nwd rename to tests/reference/guiEditor_Main_Final_0000000000010.nwd index c0316819..bc255b88 100644 --- a/tests/reference/guiEditor_Main_Final_0000000000020.nwd +++ b/tests/reference/guiEditor_Main_Final_0000000000010.nwd @@ -1,5 +1,5 @@ %%~name: New Note -%%~path: 000000000000a/0000000000020 +%%~path: 000000000000a/0000000000010 %%~kind: CHARACTER/NOTE # Jane Doe diff --git a/tests/reference/guiEditor_Main_Final_0000000000021.nwd b/tests/reference/guiEditor_Main_Final_0000000000011.nwd similarity index 74% rename from tests/reference/guiEditor_Main_Final_0000000000021.nwd rename to tests/reference/guiEditor_Main_Final_0000000000011.nwd index 5dddd23b..99705061 100644 --- a/tests/reference/guiEditor_Main_Final_0000000000021.nwd +++ b/tests/reference/guiEditor_Main_Final_0000000000011.nwd @@ -1,5 +1,5 @@ %%~name: New Note -%%~path: 0000000000009/0000000000021 +%%~path: 0000000000009/0000000000011 %%~kind: PLOT/NOTE # Main Plot diff --git a/tests/reference/guiEditor_Main_Final_0000000000022.nwd b/tests/reference/guiEditor_Main_Final_0000000000012.nwd similarity index 74% rename from tests/reference/guiEditor_Main_Final_0000000000022.nwd rename to tests/reference/guiEditor_Main_Final_0000000000012.nwd index 092f832a..ea19dae5 100644 --- a/tests/reference/guiEditor_Main_Final_0000000000022.nwd +++ b/tests/reference/guiEditor_Main_Final_0000000000012.nwd @@ -1,5 +1,5 @@ %%~name: New Note -%%~path: 000000000000b/0000000000022 +%%~path: 000000000000b/0000000000012 %%~kind: WORLD/NOTE # Main Location diff --git a/tests/reference/guiEditor_Main_Final_nwProject.nwx b/tests/reference/guiEditor_Main_Final_nwProject.nwx index fb58330e..853ea1d9 100644 --- a/tests/reference/guiEditor_Main_Final_nwProject.nwx +++ b/tests/reference/guiEditor_Main_Final_nwProject.nwx @@ -1,32 +1,33 @@ - + New Project New Novel Jane Doe 4 2 - 4 + 3 True None True None - 000000000000f - None - 0000000000008 - 0000000000008 - 163 136 27 + + 000000000000f + None + 0000000000008 + 0000000000008 + - %title% - %title% - %title% - * * * -
+ %title% + %title% + %title% + * * * +
New @@ -47,7 +48,7 @@ Novel
- + Title Page @@ -55,38 +56,38 @@ New Chapter - + New Chapter - + New Scene Plot - - + + New Note Characters - - + + New Note World - - + + New Note - + Trash diff --git a/tests/reference/guiEditor_Main_Initial_nwProject.nwx b/tests/reference/guiEditor_Main_Initial_nwProject.nwx index aa3b3057..8108fba8 100644 --- a/tests/reference/guiEditor_Main_Initial_nwProject.nwx +++ b/tests/reference/guiEditor_Main_Initial_nwProject.nwx @@ -1,5 +1,5 @@ - + New Project New Novel @@ -13,20 +13,21 @@ None False None - None - None - None - None - 9 9 0 + + None + None + None + None + - %title% - %title% - %title% - * * * -
+ %title% + %title% + %title% + * * * +
New @@ -47,7 +48,7 @@ Novel
- + Title Page @@ -55,11 +56,11 @@ New Chapter - + New Chapter - + New Scene diff --git a/tests/reference/projectXML_ReadCurrent.json b/tests/reference/projectXML_ReadCurrent.json new file mode 100644 index 00000000..141c3c3a --- /dev/null +++ b/tests/reference/projectXML_ReadCurrent.json @@ -0,0 +1,515 @@ +[ + { + "handle": "7031beac91f75", + "parent": null, + "root": "7031beac91f75", + "order": 0, + "type": "ROOT", + "class": "NOVEL", + "layout": "NO_LAYOUT", + "expanded": true, + "heading": "H0", + "charCount": 0, + "wordCount": 0, + "paraCount": 0, + "cursorPos": 0, + "label": "Novel", + "status": "sc24b8f", + "import": "ia857f0", + "active": false + }, + { + "handle": "53b69b83cdafc", + "parent": "7031beac91f75", + "root": "7031beac91f75", + "order": 0, + "type": "FILE", + "class": "NOVEL", + "layout": "DOCUMENT", + "expanded": false, + "heading": "H1", + "charCount": 93, + "wordCount": 19, + "paraCount": 2, + "cursorPos": 119, + "label": "Title Page", + "status": "sc24b8f", + "import": "ia857f0", + "active": true + }, + { + "handle": "974e400180a99", + "parent": "7031beac91f75", + "root": "7031beac91f75", + "order": 1, + "type": "FILE", + "class": "NOVEL", + "layout": "DOCUMENT", + "expanded": false, + "heading": "H0", + "charCount": 251, + "wordCount": 50, + "paraCount": 2, + "cursorPos": 277, + "label": "Page", + "status": "sf12341", + "import": "ia857f0", + "active": true + }, + { + "handle": "edca4be2fcaf8", + "parent": "7031beac91f75", + "root": "7031beac91f75", + "order": 2, + "type": "FILE", + "class": "NOVEL", + "layout": "DOCUMENT", + "expanded": false, + "heading": "H1", + "charCount": 26, + "wordCount": 6, + "paraCount": 1, + "cursorPos": 36, + "label": "Part One", + "status": "s90e6c9", + "import": "ia857f0", + "active": true + }, + { + "handle": "6a2d6d5f4f401", + "parent": "7031beac91f75", + "root": "7031beac91f75", + "order": 3, + "type": "FILE", + "class": "NOVEL", + "layout": "DOCUMENT", + "expanded": true, + "heading": "H2", + "charCount": 95, + "wordCount": 18, + "paraCount": 1, + "cursorPos": 291, + "label": "Chapter One", + "status": "sf24ce6", + "import": "ia857f0", + "active": true + }, + { + "handle": "636b6aa9b697b", + "parent": "6a2d6d5f4f401", + "root": "7031beac91f75", + "order": 0, + "type": "FILE", + "class": "NOVEL", + "layout": "DOCUMENT", + "expanded": false, + "heading": "H3", + "charCount": 2687, + "wordCount": 479, + "paraCount": 14, + "cursorPos": 67, + "label": "Making a Scene", + "status": "s90e6c9", + "import": "ia857f0", + "active": true + }, + { + "handle": "bc0cbd2a407f3", + "parent": "6a2d6d5f4f401", + "root": "7031beac91f75", + "order": 1, + "type": "FILE", + "class": "NOVEL", + "layout": "DOCUMENT", + "expanded": false, + "heading": "H3", + "charCount": 548, + "wordCount": 108, + "paraCount": 3, + "cursorPos": 465, + "label": "Another Scene", + "status": "s90e6c9", + "import": "ia857f0", + "active": true + }, + { + "handle": "ba8a28a246524", + "parent": "7031beac91f75", + "root": "7031beac91f75", + "order": 4, + "type": "FILE", + "class": "NOVEL", + "layout": "DOCUMENT", + "expanded": false, + "heading": "H2", + "charCount": 617, + "wordCount": 101, + "paraCount": 3, + "cursorPos": 310, + "label": "Interlude", + "status": "s78ea90", + "import": "ia857f0", + "active": true + }, + { + "handle": "96b68994dfa3d", + "parent": "7031beac91f75", + "root": "7031beac91f75", + "order": 5, + "type": "FILE", + "class": "NOVEL", + "layout": "NOTE", + "expanded": false, + "heading": "H1", + "charCount": 1909, + "wordCount": 346, + "paraCount": 7, + "cursorPos": 0, + "label": "A Note on Structure", + "status": "sf24ce6", + "import": "ia857f0", + "active": false + }, + { + "handle": "88706ddc78b1b", + "parent": "7031beac91f75", + "root": "7031beac91f75", + "order": 6, + "type": "FILE", + "class": "NOVEL", + "layout": "DOCUMENT", + "expanded": true, + "heading": "H2", + "charCount": 139, + "wordCount": 28, + "paraCount": 1, + "cursorPos": 188, + "label": "Chapter Two", + "status": "s90e6c9", + "import": "ia857f0", + "active": true + }, + { + "handle": "ae7339df26ded", + "parent": "88706ddc78b1b", + "root": "7031beac91f75", + "order": 0, + "type": "FILE", + "class": "NOVEL", + "layout": "DOCUMENT", + "expanded": false, + "heading": "H3", + "charCount": 189, + "wordCount": 37, + "paraCount": 1, + "cursorPos": 0, + "label": "We Found John!", + "status": "s90e6c9", + "import": "ia857f0", + "active": true + }, + { + "handle": "e5e47ebf63b1c", + "parent": null, + "root": "e5e47ebf63b1c", + "order": 1, + "type": "ROOT", + "class": "NOVEL", + "layout": "NO_LAYOUT", + "expanded": true, + "heading": "H0", + "charCount": 0, + "wordCount": 0, + "paraCount": 0, + "cursorPos": 0, + "label": "Sequel", + "status": "sf12341", + "import": "ia857f0", + "active": false + }, + { + "handle": "bacb7059e3083", + "parent": "e5e47ebf63b1c", + "root": "e5e47ebf63b1c", + "order": 0, + "type": "FILE", + "class": "NOVEL", + "layout": "DOCUMENT", + "expanded": false, + "heading": "H1", + "charCount": 27, + "wordCount": 5, + "paraCount": 1, + "cursorPos": 100, + "label": "Title Page", + "status": "sc24b8f", + "import": "ia857f0", + "active": true + }, + { + "handle": "a520879ca0b45", + "parent": "e5e47ebf63b1c", + "root": "e5e47ebf63b1c", + "order": 1, + "type": "FILE", + "class": "NOVEL", + "layout": "DOCUMENT", + "expanded": false, + "heading": "H2", + "charCount": 299, + "wordCount": 55, + "paraCount": 2, + "cursorPos": 104, + "label": "Chapter One", + "status": "s90e6c9", + "import": "ia857f0", + "active": true + }, + { + "handle": "f6622b4617424", + "parent": null, + "root": "f6622b4617424", + "order": 2, + "type": "ROOT", + "class": "CHARACTER", + "layout": "NO_LAYOUT", + "expanded": true, + "heading": "H0", + "charCount": 0, + "wordCount": 0, + "paraCount": 0, + "cursorPos": 0, + "label": "Characters", + "status": "sf12341", + "import": "ia857f0", + "active": false + }, + { + "handle": "f7e2d9f330615", + "parent": "f6622b4617424", + "root": "f6622b4617424", + "order": 0, + "type": "FOLDER", + "class": "CHARACTER", + "layout": "NO_LAYOUT", + "expanded": true, + "heading": "H0", + "charCount": 0, + "wordCount": 0, + "paraCount": 0, + "cursorPos": 0, + "label": "Main Characters", + "status": "sf12341", + "import": "ia857f0", + "active": false + }, + { + "handle": "14298de4d9524", + "parent": "f7e2d9f330615", + "root": "f6622b4617424", + "order": 0, + "type": "FILE", + "class": "CHARACTER", + "layout": "NOTE", + "expanded": false, + "heading": "H1", + "charCount": 49, + "wordCount": 9, + "paraCount": 1, + "cursorPos": 24, + "label": "John Smith", + "status": "sf12341", + "import": "icfb3a5", + "active": true + }, + { + "handle": "bb2c23b3c42cc", + "parent": "f7e2d9f330615", + "root": "f6622b4617424", + "order": 1, + "type": "FILE", + "class": "CHARACTER", + "layout": "NOTE", + "expanded": false, + "heading": "H1", + "charCount": 55, + "wordCount": 9, + "paraCount": 1, + "cursorPos": 25, + "label": "Jane Smith", + "status": "sf12341", + "import": "i2d7a54", + "active": true + }, + { + "handle": "15c4492bd5107", + "parent": null, + "root": "15c4492bd5107", + "order": 3, + "type": "ROOT", + "class": "WORLD", + "layout": "NO_LAYOUT", + "expanded": true, + "heading": "H0", + "charCount": 0, + "wordCount": 0, + "paraCount": 0, + "cursorPos": 0, + "label": "Locations", + "status": "sf12341", + "import": "ia857f0", + "active": false + }, + { + "handle": "b3e74dbc1f584", + "parent": "15c4492bd5107", + "root": "15c4492bd5107", + "order": 0, + "type": "FILE", + "class": "WORLD", + "layout": "NOTE", + "expanded": false, + "heading": "H1", + "charCount": 76, + "wordCount": 15, + "paraCount": 1, + "cursorPos": 20, + "label": "Earth", + "status": "sf12341", + "import": "i56be10", + "active": true + }, + { + "handle": "f1471bef9f2ae", + "parent": "15c4492bd5107", + "root": "15c4492bd5107", + "order": 1, + "type": "FILE", + "class": "WORLD", + "layout": "NOTE", + "expanded": false, + "heading": "H1", + "charCount": 115, + "wordCount": 24, + "paraCount": 1, + "cursorPos": 133, + "label": "Space", + "status": "sf12341", + "import": "icfb3a5", + "active": true + }, + { + "handle": "5eaea4e8cdee8", + "parent": "15c4492bd5107", + "root": "15c4492bd5107", + "order": 2, + "type": "FILE", + "class": "WORLD", + "layout": "NOTE", + "expanded": false, + "heading": "H1", + "charCount": 28, + "wordCount": 6, + "paraCount": 1, + "cursorPos": 45, + "label": "Mars", + "status": "sf12341", + "import": "i2d7a54", + "active": true + }, + { + "handle": "6827118336ac1", + "parent": null, + "root": "6827118336ac1", + "order": 4, + "type": "ROOT", + "class": "ARCHIVE", + "layout": "NO_LAYOUT", + "expanded": true, + "heading": "H0", + "charCount": 0, + "wordCount": 0, + "paraCount": 0, + "cursorPos": 0, + "label": "Archive", + "status": "sf12341", + "import": "ia857f0", + "active": false + }, + { + "handle": "ae9bf3c3ea159", + "parent": "6827118336ac1", + "root": "6827118336ac1", + "order": 0, + "type": "FOLDER", + "class": "ARCHIVE", + "layout": "NO_LAYOUT", + "expanded": true, + "heading": "H0", + "charCount": 0, + "wordCount": 0, + "paraCount": 0, + "cursorPos": 0, + "label": "Scenes", + "status": "sf12341", + "import": "ia857f0", + "active": false + }, + { + "handle": "8a5deb88c0e97", + "parent": "ae9bf3c3ea159", + "root": "6827118336ac1", + "order": 0, + "type": "FILE", + "class": "ARCHIVE", + "layout": "DOCUMENT", + "expanded": false, + "heading": "H3", + "charCount": 232, + "wordCount": 42, + "paraCount": 1, + "cursorPos": 239, + "label": "Old File", + "status": "s90e6c9", + "import": "ia857f0", + "active": true + }, + { + "handle": "98acd8c76c93a", + "parent": null, + "root": "98acd8c76c93a", + "order": 5, + "type": "ROOT", + "class": "TRASH", + "layout": "NO_LAYOUT", + "expanded": true, + "heading": "H0", + "charCount": 0, + "wordCount": 0, + "paraCount": 0, + "cursorPos": 0, + "label": "Trash", + "status": "sf12341", + "import": "ia857f0", + "active": false + }, + { + "handle": "b8136a5a774a0", + "parent": "98acd8c76c93a", + "root": "98acd8c76c93a", + "order": 0, + "type": "FILE", + "class": "TRASH", + "layout": "DOCUMENT", + "expanded": false, + "heading": "H3", + "charCount": 30, + "wordCount": 6, + "paraCount": 1, + "cursorPos": 36, + "label": "Delete Me!", + "status": "sf12341", + "import": "ia857f0", + "active": true + } +] \ No newline at end of file diff --git a/tests/reference/projectXML_ReadLegacy10.json b/tests/reference/projectXML_ReadLegacy10.json new file mode 100644 index 00000000..27e9da17 --- /dev/null +++ b/tests/reference/projectXML_ReadLegacy10.json @@ -0,0 +1,362 @@ +[ + { + "handle": "7031beac91f75", + "parent": null, + "root": null, + "order": 0, + "heading": "H0", + "label": "Novel", + "type": "ROOT", + "class": "NOVEL", + "expanded": true, + "status": "s000002" + }, + { + "handle": "53b69b83cdafc", + "parent": "7031beac91f75", + "root": null, + "order": 0, + "heading": "H0", + "label": "Title Page", + "type": "FILE", + "class": "NOVEL", + "expanded": false, + "active": true, + "layout": "DOCUMENT", + "charCount": 72, + "wordCount": 15, + "paraCount": 2, + "cursorPos": 78, + "status": "s000002" + }, + { + "handle": "974e400180a99", + "parent": "7031beac91f75", + "root": null, + "order": 1, + "heading": "H0", + "label": "Page", + "type": "FILE", + "class": "NOVEL", + "expanded": false, + "active": true, + "layout": "DOCUMENT", + "charCount": 208, + "wordCount": 40, + "paraCount": 2, + "cursorPos": 213, + "status": "s000000" + }, + { + "handle": "edca4be2fcaf8", + "parent": "7031beac91f75", + "root": null, + "order": 2, + "heading": "H0", + "label": "Part One", + "type": "FILE", + "class": "NOVEL", + "expanded": false, + "active": true, + "layout": "DOCUMENT", + "charCount": 23, + "wordCount": 5, + "paraCount": 1, + "cursorPos": 0, + "status": "s000000" + }, + { + "handle": "e7ded148d6e4a", + "parent": "7031beac91f75", + "root": null, + "order": 3, + "heading": "H0", + "label": "A Folder", + "type": "FOLDER", + "class": "NOVEL", + "expanded": true, + "status": "s000003" + }, + { + "handle": "6a2d6d5f4f401", + "parent": "e7ded148d6e4a", + "root": null, + "order": 0, + "heading": "H0", + "label": "Chapter One", + "type": "FILE", + "class": "NOVEL", + "expanded": false, + "active": true, + "layout": "DOCUMENT", + "charCount": 12, + "wordCount": 3, + "paraCount": 0, + "cursorPos": 215, + "status": "s000001" + }, + { + "handle": "636b6aa9b697b", + "parent": "e7ded148d6e4a", + "root": null, + "order": 1, + "heading": "H0", + "label": "Making a Scene", + "type": "FILE", + "class": "NOVEL", + "expanded": false, + "active": true, + "layout": "DOCUMENT", + "charCount": 1199, + "wordCount": 216, + "paraCount": 7, + "cursorPos": 527, + "status": "s000003" + }, + { + "handle": "bc0cbd2a407f3", + "parent": "e7ded148d6e4a", + "root": null, + "order": 2, + "heading": "H0", + "label": "Another Scene", + "type": "FILE", + "class": "NOVEL", + "expanded": false, + "active": true, + "layout": "DOCUMENT", + "charCount": 476, + "wordCount": 93, + "paraCount": 3, + "cursorPos": 551, + "status": "s000003" + }, + { + "handle": "ba8a28a246524", + "parent": "e7ded148d6e4a", + "root": null, + "order": 3, + "heading": "H0", + "label": "Interlude", + "type": "FILE", + "class": "NOVEL", + "expanded": false, + "active": true, + "layout": "DOCUMENT", + "charCount": 633, + "wordCount": 101, + "paraCount": 3, + "cursorPos": 1238, + "status": "s000006" + }, + { + "handle": "96b68994dfa3d", + "parent": "e7ded148d6e4a", + "root": null, + "order": 4, + "heading": "H0", + "label": "A Note on Structure", + "type": "FILE", + "class": "NOVEL", + "expanded": false, + "active": false, + "layout": "NOTE", + "charCount": 1692, + "wordCount": 313, + "paraCount": 6, + "cursorPos": 1721, + "status": "s000004" + }, + { + "handle": "88706ddc78b1b", + "parent": "e7ded148d6e4a", + "root": null, + "order": 5, + "heading": "H0", + "label": "Chapter Two", + "type": "FILE", + "class": "NOVEL", + "expanded": false, + "active": true, + "layout": "DOCUMENT", + "charCount": 139, + "wordCount": 28, + "paraCount": 1, + "cursorPos": 343, + "status": "s000003" + }, + { + "handle": "ae7339df26ded", + "parent": "e7ded148d6e4a", + "root": null, + "order": 6, + "heading": "H0", + "label": "We Found John!", + "type": "FILE", + "class": "NOVEL", + "expanded": false, + "active": true, + "layout": "DOCUMENT", + "charCount": 189, + "wordCount": 37, + "paraCount": 1, + "cursorPos": 224, + "status": "s000003" + }, + { + "handle": "f6622b4617424", + "parent": null, + "root": null, + "order": 1, + "heading": "H0", + "label": "Characters", + "type": "ROOT", + "class": "CHARACTER", + "expanded": true, + "import": null + }, + { + "handle": "f7e2d9f330615", + "parent": "f6622b4617424", + "root": null, + "order": 0, + "heading": "H0", + "label": "Main Characters", + "type": "FOLDER", + "class": "CHARACTER", + "expanded": true, + "import": null + }, + { + "handle": "14298de4d9524", + "parent": "f7e2d9f330615", + "root": null, + "order": 0, + "heading": "H0", + "label": "John Smith", + "type": "FILE", + "class": "CHARACTER", + "expanded": false, + "active": true, + "layout": "NOTE", + "charCount": 49, + "wordCount": 9, + "paraCount": 1, + "cursorPos": 24, + "import": "i000008" + }, + { + "handle": "bb2c23b3c42cc", + "parent": "f7e2d9f330615", + "root": null, + "order": 1, + "heading": "H0", + "label": "Jane Smith", + "type": "FILE", + "class": "CHARACTER", + "expanded": false, + "active": true, + "layout": "NOTE", + "charCount": 55, + "wordCount": 9, + "paraCount": 1, + "cursorPos": 25, + "import": "i000009" + }, + { + "handle": "15c4492bd5107", + "parent": null, + "root": null, + "order": 2, + "heading": "H0", + "label": "Locations", + "type": "ROOT", + "class": "WORLD", + "expanded": true, + "import": null + }, + { + "handle": "b3e74dbc1f584", + "parent": "15c4492bd5107", + "root": null, + "order": 0, + "heading": "H0", + "label": "Earth", + "type": "FILE", + "class": "WORLD", + "expanded": false, + "active": true, + "layout": "NOTE", + "charCount": 76, + "wordCount": 15, + "paraCount": 1, + "cursorPos": 20, + "import": "i00000a" + }, + { + "handle": "f1471bef9f2ae", + "parent": "15c4492bd5107", + "root": null, + "order": 1, + "heading": "H0", + "label": "Space", + "type": "FILE", + "class": "WORLD", + "expanded": false, + "active": true, + "layout": "NOTE", + "charCount": 115, + "wordCount": 24, + "paraCount": 1, + "cursorPos": 133, + "import": "i000008" + }, + { + "handle": "5eaea4e8cdee8", + "parent": "15c4492bd5107", + "root": null, + "order": 2, + "heading": "H0", + "label": "Mars", + "type": "FILE", + "class": "WORLD", + "expanded": false, + "active": true, + "layout": "NOTE", + "charCount": 28, + "wordCount": 6, + "paraCount": 1, + "cursorPos": 45, + "import": "i000009" + }, + { + "handle": "98acd8c76c93a", + "parent": null, + "root": null, + "order": 3, + "heading": "H0", + "label": "Trash", + "type": "ROOT", + "class": "TRASH", + "expanded": true, + "import": null + }, + { + "handle": "b8136a5a774a0", + "parent": "98acd8c76c93a", + "root": null, + "order": 0, + "heading": "H0", + "label": "Delete Me!", + "type": "FILE", + "class": "NOVEL", + "expanded": false, + "active": true, + "layout": "DOCUMENT", + "charCount": 0, + "wordCount": 0, + "paraCount": 0, + "cursorPos": 36, + "status": "s000000" + } +] \ No newline at end of file diff --git a/tests/reference/projectXML_ReadLegacy10.nwx b/tests/reference/projectXML_ReadLegacy10.nwx new file mode 100644 index 00000000..8e7f0a99 --- /dev/null +++ b/tests/reference/projectXML_ReadLegacy10.nwx @@ -0,0 +1,143 @@ + + + + Sample Project + Sample Project + Jane Smith + Jay Doh + 0 + 0 + 1000 + + + True + None + True + None + 0 + 0 + + None + None + None + None + + + B + E + D + + + %title% + Chapter %ch%: %title% + %title% + Scene %ch%.%sc%: %title% + + + + New + Notes + Started + 1st Draft + 2nd Draft + 3rd Draft + Finished + + + None + Minor + Major + Main + + + + + + Novel + + + + Title Page + + + + Page + + + + Part One + + + + A Folder + + + + Chapter One + + + + Making a Scene + + + + Another Scene + + + + Interlude + + + + A Note on Structure + + + + Chapter Two + + + + We Found John! + + + + Characters + + + + Main Characters + + + + John Smith + + + + Jane Smith + + + + Locations + + + + Earth + + + + Space + + + + Mars + + + + Trash + + + + Delete Me! + + + diff --git a/tests/reference/projectXML_ReadLegacy11.json b/tests/reference/projectXML_ReadLegacy11.json new file mode 100644 index 00000000..16e84adc --- /dev/null +++ b/tests/reference/projectXML_ReadLegacy11.json @@ -0,0 +1,346 @@ +[ + { + "handle": "7031beac91f75", + "parent": null, + "root": null, + "order": 0, + "heading": "H0", + "label": "Novel", + "type": "ROOT", + "class": "NOVEL", + "expanded": true, + "status": "s000002" + }, + { + "handle": "53b69b83cdafc", + "parent": "7031beac91f75", + "root": null, + "order": 0, + "heading": "H0", + "label": "Title Page", + "type": "FILE", + "class": "NOVEL", + "active": true, + "layout": "DOCUMENT", + "charCount": 72, + "wordCount": 15, + "paraCount": 2, + "cursorPos": 78, + "status": "s000002" + }, + { + "handle": "974e400180a99", + "parent": "7031beac91f75", + "root": null, + "order": 1, + "heading": "H0", + "label": "Page", + "type": "FILE", + "class": "NOVEL", + "active": true, + "layout": "DOCUMENT", + "charCount": 210, + "wordCount": 40, + "paraCount": 2, + "cursorPos": 213, + "status": "s000000" + }, + { + "handle": "edca4be2fcaf8", + "parent": "7031beac91f75", + "root": null, + "order": 2, + "heading": "H0", + "label": "Part One", + "type": "FILE", + "class": "NOVEL", + "active": true, + "layout": "DOCUMENT", + "charCount": 23, + "wordCount": 5, + "paraCount": 1, + "cursorPos": 0, + "status": "s000000" + }, + { + "handle": "e7ded148d6e4a", + "parent": "7031beac91f75", + "root": null, + "order": 3, + "heading": "H0", + "label": "A Folder", + "type": "FOLDER", + "class": "NOVEL", + "expanded": true, + "status": "s000003" + }, + { + "handle": "6a2d6d5f4f401", + "parent": "e7ded148d6e4a", + "root": null, + "order": 0, + "heading": "H0", + "label": "Chapter One", + "type": "FILE", + "class": "NOVEL", + "active": true, + "layout": "DOCUMENT", + "charCount": 12, + "wordCount": 3, + "paraCount": 0, + "cursorPos": 215, + "status": "s000001" + }, + { + "handle": "636b6aa9b697b", + "parent": "e7ded148d6e4a", + "root": null, + "order": 1, + "heading": "H0", + "label": "Making a Scene", + "type": "FILE", + "class": "NOVEL", + "active": true, + "layout": "DOCUMENT", + "charCount": 1483, + "wordCount": 263, + "paraCount": 8, + "cursorPos": 1086, + "status": "s000003" + }, + { + "handle": "bc0cbd2a407f3", + "parent": "e7ded148d6e4a", + "root": null, + "order": 2, + "heading": "H0", + "label": "Another Scene", + "type": "FILE", + "class": "NOVEL", + "active": true, + "layout": "DOCUMENT", + "charCount": 476, + "wordCount": 93, + "paraCount": 3, + "cursorPos": 428, + "status": "s000003" + }, + { + "handle": "ba8a28a246524", + "parent": "e7ded148d6e4a", + "root": null, + "order": 3, + "heading": "H0", + "label": "Interlude", + "type": "FILE", + "class": "NOVEL", + "active": true, + "layout": "DOCUMENT", + "charCount": 633, + "wordCount": 101, + "paraCount": 3, + "cursorPos": 1238, + "status": "s000006" + }, + { + "handle": "96b68994dfa3d", + "parent": "e7ded148d6e4a", + "root": null, + "order": 4, + "heading": "H0", + "label": "A Note on Structure", + "type": "FILE", + "class": "NOVEL", + "active": false, + "layout": "NOTE", + "charCount": 1692, + "wordCount": 313, + "paraCount": 6, + "cursorPos": 1721, + "status": "s000004" + }, + { + "handle": "88706ddc78b1b", + "parent": "e7ded148d6e4a", + "root": null, + "order": 5, + "heading": "H0", + "label": "Chapter Two", + "type": "FILE", + "class": "NOVEL", + "active": true, + "layout": "DOCUMENT", + "charCount": 139, + "wordCount": 28, + "paraCount": 1, + "cursorPos": 343, + "status": "s000003" + }, + { + "handle": "ae7339df26ded", + "parent": "e7ded148d6e4a", + "root": null, + "order": 6, + "heading": "H0", + "label": "We Found John!", + "type": "FILE", + "class": "NOVEL", + "active": true, + "layout": "DOCUMENT", + "charCount": 189, + "wordCount": 37, + "paraCount": 1, + "cursorPos": 224, + "status": "s000003" + }, + { + "handle": "f6622b4617424", + "parent": null, + "root": null, + "order": 1, + "heading": "H0", + "label": "Characters", + "type": "ROOT", + "class": "CHARACTER", + "expanded": true, + "import": null + }, + { + "handle": "f7e2d9f330615", + "parent": "f6622b4617424", + "root": null, + "order": 0, + "heading": "H0", + "label": "Main Characters", + "type": "FOLDER", + "class": "CHARACTER", + "expanded": true, + "import": null + }, + { + "handle": "14298de4d9524", + "parent": "f7e2d9f330615", + "root": null, + "order": 0, + "heading": "H0", + "label": "John Smith", + "type": "FILE", + "class": "CHARACTER", + "active": true, + "layout": "NOTE", + "charCount": 49, + "wordCount": 9, + "paraCount": 1, + "cursorPos": 24, + "import": "i000008" + }, + { + "handle": "bb2c23b3c42cc", + "parent": "f7e2d9f330615", + "root": null, + "order": 1, + "heading": "H0", + "label": "Jane Smith", + "type": "FILE", + "class": "CHARACTER", + "active": true, + "layout": "NOTE", + "charCount": 55, + "wordCount": 9, + "paraCount": 1, + "cursorPos": 25, + "import": "i000009" + }, + { + "handle": "15c4492bd5107", + "parent": null, + "root": null, + "order": 2, + "heading": "H0", + "label": "Locations", + "type": "ROOT", + "class": "WORLD", + "expanded": true, + "import": null + }, + { + "handle": "b3e74dbc1f584", + "parent": "15c4492bd5107", + "root": null, + "order": 0, + "heading": "H0", + "label": "Earth", + "type": "FILE", + "class": "WORLD", + "active": true, + "layout": "NOTE", + "charCount": 76, + "wordCount": 15, + "paraCount": 1, + "cursorPos": 20, + "import": "i00000a" + }, + { + "handle": "f1471bef9f2ae", + "parent": "15c4492bd5107", + "root": null, + "order": 1, + "heading": "H0", + "label": "Space", + "type": "FILE", + "class": "WORLD", + "active": true, + "layout": "NOTE", + "charCount": 115, + "wordCount": 24, + "paraCount": 1, + "cursorPos": 133, + "import": "i000008" + }, + { + "handle": "5eaea4e8cdee8", + "parent": "15c4492bd5107", + "root": null, + "order": 2, + "heading": "H0", + "label": "Mars", + "type": "FILE", + "class": "WORLD", + "active": true, + "layout": "NOTE", + "charCount": 28, + "wordCount": 6, + "paraCount": 1, + "cursorPos": 45, + "import": "i000009" + }, + { + "handle": "98acd8c76c93a", + "parent": null, + "root": null, + "order": 3, + "heading": "H0", + "label": "Trash", + "type": "ROOT", + "class": "TRASH", + "expanded": true, + "import": null + }, + { + "handle": "b8136a5a774a0", + "parent": "98acd8c76c93a", + "root": null, + "order": 0, + "heading": "H0", + "label": "Delete Me!", + "type": "FILE", + "class": "NOVEL", + "active": true, + "layout": "DOCUMENT", + "charCount": 30, + "wordCount": 6, + "paraCount": 1, + "cursorPos": 36, + "status": "s000000" + } +] \ No newline at end of file diff --git a/tests/reference/projectXML_ReadLegacy11.nwx b/tests/reference/projectXML_ReadLegacy11.nwx new file mode 100644 index 00000000..fce991d3 --- /dev/null +++ b/tests/reference/projectXML_ReadLegacy11.nwx @@ -0,0 +1,143 @@ + + + + Sample Project + Sample Project + Jane Smith + Jay Doh + 5 + 10 + 1000 + + + True + None + True + None + 0 + 0 + + None + None + None + None + + + B + E + D + + + %title% + Chapter %ch%: %title% + %title% + Scene %ch%.%sc%: %title% + + + + New + Notes + Started + 1st Draft + 2nd Draft + 3rd Draft + Finished + + + None + Minor + Major + Main + + + + + + Novel + + + + Title Page + + + + Page + + + + Part One + + + + A Folder + + + + Chapter One + + + + Making a Scene + + + + Another Scene + + + + Interlude + + + + A Note on Structure + + + + Chapter Two + + + + We Found John! + + + + Characters + + + + Main Characters + + + + John Smith + + + + Jane Smith + + + + Locations + + + + Earth + + + + Space + + + + Mars + + + + Trash + + + + Delete Me! + + + diff --git a/tests/reference/projectXML_ReadLegacy12.json b/tests/reference/projectXML_ReadLegacy12.json new file mode 100644 index 00000000..917cb633 --- /dev/null +++ b/tests/reference/projectXML_ReadLegacy12.json @@ -0,0 +1,387 @@ +[ + { + "handle": "7031beac91f75", + "parent": null, + "root": null, + "order": 0, + "heading": "H0", + "label": "Novel", + "type": "ROOT", + "class": "NOVEL", + "expanded": true, + "status": "s000002" + }, + { + "handle": "53b69b83cdafc", + "parent": "7031beac91f75", + "root": null, + "order": 0, + "heading": "H0", + "label": "Title Page", + "type": "FILE", + "class": "NOVEL", + "active": true, + "layout": "DOCUMENT", + "charCount": 241, + "wordCount": 42, + "paraCount": 3, + "cursorPos": 252, + "status": "s000002" + }, + { + "handle": "974e400180a99", + "parent": "7031beac91f75", + "root": null, + "order": 1, + "heading": "H0", + "label": "Page", + "type": "FILE", + "class": "NOVEL", + "active": true, + "layout": "DOCUMENT", + "charCount": 125, + "wordCount": 26, + "paraCount": 2, + "cursorPos": 127, + "status": "s000000" + }, + { + "handle": "edca4be2fcaf8", + "parent": "7031beac91f75", + "root": null, + "order": 2, + "heading": "H0", + "label": "Part One", + "type": "FILE", + "class": "NOVEL", + "active": true, + "layout": "DOCUMENT", + "charCount": 26, + "wordCount": 6, + "paraCount": 1, + "cursorPos": 30, + "status": "s000000" + }, + { + "handle": "e7ded148d6e4a", + "parent": "7031beac91f75", + "root": null, + "order": 3, + "heading": "H0", + "label": "A Folder", + "type": "FOLDER", + "class": "NOVEL", + "expanded": true, + "status": "s000003" + }, + { + "handle": "6a2d6d5f4f401", + "parent": "e7ded148d6e4a", + "root": null, + "order": 0, + "heading": "H0", + "label": "Chapter One", + "type": "FILE", + "class": "NOVEL", + "active": true, + "layout": "DOCUMENT", + "charCount": 75, + "wordCount": 14, + "paraCount": 1, + "cursorPos": 279, + "status": "s000001" + }, + { + "handle": "636b6aa9b697b", + "parent": "e7ded148d6e4a", + "root": null, + "order": 1, + "heading": "H0", + "label": "Making a Scene", + "type": "FILE", + "class": "NOVEL", + "active": true, + "layout": "DOCUMENT", + "charCount": 2429, + "wordCount": 432, + "paraCount": 14, + "cursorPos": 61, + "status": "s000003" + }, + { + "handle": "bc0cbd2a407f3", + "parent": "e7ded148d6e4a", + "root": null, + "order": 2, + "heading": "H0", + "label": "Another Scene", + "type": "FILE", + "class": "NOVEL", + "active": true, + "layout": "DOCUMENT", + "charCount": 476, + "wordCount": 93, + "paraCount": 3, + "cursorPos": 577, + "status": "s000003" + }, + { + "handle": "ba8a28a246524", + "parent": "e7ded148d6e4a", + "root": null, + "order": 3, + "heading": "H0", + "label": "Interlude", + "type": "FILE", + "class": "NOVEL", + "active": true, + "layout": "DOCUMENT", + "charCount": 617, + "wordCount": 101, + "paraCount": 3, + "cursorPos": 1137, + "status": "s000000" + }, + { + "handle": "96b68994dfa3d", + "parent": "e7ded148d6e4a", + "root": null, + "order": 4, + "heading": "H0", + "label": "A Note on Structure", + "type": "FILE", + "class": "NOVEL", + "active": false, + "layout": "NOTE", + "charCount": 1692, + "wordCount": 313, + "paraCount": 6, + "cursorPos": 1110, + "status": "s000004" + }, + { + "handle": "88706ddc78b1b", + "parent": "e7ded148d6e4a", + "root": null, + "order": 5, + "heading": "H0", + "label": "Chapter Two", + "type": "FILE", + "class": "NOVEL", + "active": true, + "layout": "DOCUMENT", + "charCount": 139, + "wordCount": 28, + "paraCount": 1, + "cursorPos": 343, + "status": "s000003" + }, + { + "handle": "ae7339df26ded", + "parent": "e7ded148d6e4a", + "root": null, + "order": 6, + "heading": "H0", + "label": "We Found John!", + "type": "FILE", + "class": "NOVEL", + "active": true, + "layout": "DOCUMENT", + "charCount": 189, + "wordCount": 37, + "paraCount": 1, + "cursorPos": 224, + "status": "s000003" + }, + { + "handle": "f6622b4617424", + "parent": null, + "root": null, + "order": 1, + "heading": "H0", + "label": "Characters", + "type": "ROOT", + "class": "CHARACTER", + "expanded": true, + "import": null + }, + { + "handle": "f7e2d9f330615", + "parent": "f6622b4617424", + "root": null, + "order": 0, + "heading": "H0", + "label": "Main Characters", + "type": "FOLDER", + "class": "CHARACTER", + "expanded": true, + "import": null + }, + { + "handle": "14298de4d9524", + "parent": "f7e2d9f330615", + "root": null, + "order": 0, + "heading": "H0", + "label": "John Smith", + "type": "FILE", + "class": "CHARACTER", + "active": true, + "layout": "NOTE", + "charCount": 49, + "wordCount": 9, + "paraCount": 1, + "cursorPos": 24, + "import": "i000008" + }, + { + "handle": "bb2c23b3c42cc", + "parent": "f7e2d9f330615", + "root": null, + "order": 1, + "heading": "H0", + "label": "Jane Smith", + "type": "FILE", + "class": "CHARACTER", + "active": true, + "layout": "NOTE", + "charCount": 55, + "wordCount": 9, + "paraCount": 1, + "cursorPos": 25, + "import": "i000009" + }, + { + "handle": "15c4492bd5107", + "parent": null, + "root": null, + "order": 2, + "heading": "H0", + "label": "Locations", + "type": "ROOT", + "class": "WORLD", + "expanded": true, + "import": null + }, + { + "handle": "b3e74dbc1f584", + "parent": "15c4492bd5107", + "root": null, + "order": 0, + "heading": "H0", + "label": "Earth", + "type": "FILE", + "class": "WORLD", + "active": true, + "layout": "NOTE", + "charCount": 76, + "wordCount": 15, + "paraCount": 1, + "cursorPos": 20, + "import": "i00000a" + }, + { + "handle": "f1471bef9f2ae", + "parent": "15c4492bd5107", + "root": null, + "order": 1, + "heading": "H0", + "label": "Space", + "type": "FILE", + "class": "WORLD", + "active": true, + "layout": "NOTE", + "charCount": 115, + "wordCount": 24, + "paraCount": 1, + "cursorPos": 133, + "import": "i000008" + }, + { + "handle": "5eaea4e8cdee8", + "parent": "15c4492bd5107", + "root": null, + "order": 2, + "heading": "H0", + "label": "Mars", + "type": "FILE", + "class": "WORLD", + "active": true, + "layout": "NOTE", + "charCount": 28, + "wordCount": 6, + "paraCount": 1, + "cursorPos": 45, + "import": "i000009" + }, + { + "handle": "6827118336ac1", + "parent": null, + "root": null, + "order": 3, + "heading": "H0", + "label": "Outtakes", + "type": "ROOT", + "class": "ARCHIVE", + "expanded": true, + "status": null + }, + { + "handle": "ae9bf3c3ea159", + "parent": "6827118336ac1", + "root": null, + "order": 0, + "heading": "H0", + "label": "Scenes", + "type": "FOLDER", + "class": "ARCHIVE", + "expanded": true, + "status": null + }, + { + "handle": "8a5deb88c0e97", + "parent": "ae9bf3c3ea159", + "root": null, + "order": 0, + "heading": "H0", + "label": "Old File", + "type": "FILE", + "class": "NOVEL", + "active": true, + "layout": "DOCUMENT", + "charCount": 315, + "wordCount": 55, + "paraCount": 1, + "cursorPos": 322, + "status": "s000003" + }, + { + "handle": "98acd8c76c93a", + "parent": null, + "root": null, + "order": 4, + "heading": "H0", + "label": "Trash", + "type": "ROOT", + "class": "TRASH", + "expanded": true, + "import": null + }, + { + "handle": "b8136a5a774a0", + "parent": "98acd8c76c93a", + "root": null, + "order": 0, + "heading": "H0", + "label": "Delete Me!", + "type": "FILE", + "class": "NOVEL", + "active": true, + "layout": "DOCUMENT", + "charCount": 30, + "wordCount": 6, + "paraCount": 1, + "cursorPos": 36, + "status": "s000000" + } +] \ No newline at end of file diff --git a/tests/reference/projectXML_ReadLegacy12.nwx b/tests/reference/projectXML_ReadLegacy12.nwx new file mode 100644 index 00000000..9de6966b --- /dev/null +++ b/tests/reference/projectXML_ReadLegacy12.nwx @@ -0,0 +1,155 @@ + + + + Sample Project + Sample Project + Jane Smith + Jay Doh + 5 + 10 + 1000 + + + True + en_GB + True + en_GB + 840 + 376 + + None + None + None + None + + + B + E + D + + + %title% + Chapter %chw%: %title% + %title% + Scene %ch%.%sc%: %title% + + + + New + Notes + Started + 1st Draft + 2nd Draft + 3rd Draft + Finished + + + None + Minor + Major + Main + + + + + + Novel + + + + Title Page + + + + Page + + + + Part One + + + + A Folder + + + + Chapter One + + + + Making a Scene + + + + Another Scene + + + + Interlude + + + + A Note on Structure + + + + Chapter Two + + + + We Found John! + + + + Characters + + + + Main Characters + + + + John Smith + + + + Jane Smith + + + + Locations + + + + Earth + + + + Space + + + + Mars + + + + Outtakes + + + + Scenes + + + + Old File + + + + Trash + + + + Delete Me! + + + diff --git a/tests/reference/projectXML_ReadLegacy13.json b/tests/reference/projectXML_ReadLegacy13.json new file mode 100644 index 00000000..55508758 --- /dev/null +++ b/tests/reference/projectXML_ReadLegacy13.json @@ -0,0 +1,387 @@ +[ + { + "handle": "7031beac91f75", + "parent": null, + "root": null, + "order": 0, + "heading": "H0", + "label": "Novel", + "type": "ROOT", + "class": "NOVEL", + "expanded": true, + "status": "s000002" + }, + { + "handle": "53b69b83cdafc", + "parent": "7031beac91f75", + "root": null, + "order": 0, + "heading": "H0", + "label": "Title Page", + "type": "FILE", + "class": "NOVEL", + "active": true, + "layout": "DOCUMENT", + "charCount": 93, + "wordCount": 19, + "paraCount": 2, + "cursorPos": 2, + "status": "s000002" + }, + { + "handle": "974e400180a99", + "parent": "7031beac91f75", + "root": null, + "order": 1, + "heading": "H0", + "label": "Page", + "type": "FILE", + "class": "NOVEL", + "active": true, + "layout": "DOCUMENT", + "charCount": 186, + "wordCount": 39, + "paraCount": 2, + "cursorPos": 212, + "status": "s000000" + }, + { + "handle": "edca4be2fcaf8", + "parent": "7031beac91f75", + "root": null, + "order": 2, + "heading": "H0", + "label": "Part One", + "type": "FILE", + "class": "NOVEL", + "active": true, + "layout": "DOCUMENT", + "charCount": 26, + "wordCount": 6, + "paraCount": 1, + "cursorPos": 33, + "status": "s000000" + }, + { + "handle": "e7ded148d6e4a", + "parent": "7031beac91f75", + "root": null, + "order": 3, + "heading": "H0", + "label": "A Folder", + "type": "FOLDER", + "class": "NOVEL", + "expanded": true, + "status": "s000003" + }, + { + "handle": "6a2d6d5f4f401", + "parent": "e7ded148d6e4a", + "root": null, + "order": 0, + "heading": "H0", + "label": "Chapter One", + "type": "FILE", + "class": "NOVEL", + "active": true, + "layout": "DOCUMENT", + "charCount": 75, + "wordCount": 14, + "paraCount": 1, + "cursorPos": 279, + "status": "s000001" + }, + { + "handle": "636b6aa9b697b", + "parent": "e7ded148d6e4a", + "root": null, + "order": 1, + "heading": "H0", + "label": "Making a Scene", + "type": "FILE", + "class": "NOVEL", + "active": true, + "layout": "DOCUMENT", + "charCount": 2429, + "wordCount": 432, + "paraCount": 14, + "cursorPos": 62, + "status": "s000003" + }, + { + "handle": "bc0cbd2a407f3", + "parent": "e7ded148d6e4a", + "root": null, + "order": 2, + "heading": "H0", + "label": "Another Scene", + "type": "FILE", + "class": "NOVEL", + "active": true, + "layout": "DOCUMENT", + "charCount": 476, + "wordCount": 93, + "paraCount": 3, + "cursorPos": 577, + "status": "s000003" + }, + { + "handle": "ba8a28a246524", + "parent": "e7ded148d6e4a", + "root": null, + "order": 3, + "heading": "H0", + "label": "Interlude", + "type": "FILE", + "class": "NOVEL", + "active": true, + "layout": "DOCUMENT", + "charCount": 617, + "wordCount": 101, + "paraCount": 3, + "cursorPos": 4, + "status": "s000000" + }, + { + "handle": "96b68994dfa3d", + "parent": "e7ded148d6e4a", + "root": null, + "order": 4, + "heading": "H0", + "label": "A Note on Structure", + "type": "FILE", + "class": "NOVEL", + "active": false, + "layout": "NOTE", + "charCount": 1692, + "wordCount": 313, + "paraCount": 6, + "cursorPos": 1110, + "status": "s000004" + }, + { + "handle": "88706ddc78b1b", + "parent": "e7ded148d6e4a", + "root": null, + "order": 5, + "heading": "H0", + "label": "Chapter Two", + "type": "FILE", + "class": "NOVEL", + "active": true, + "layout": "DOCUMENT", + "charCount": 139, + "wordCount": 28, + "paraCount": 1, + "cursorPos": 343, + "status": "s000003" + }, + { + "handle": "ae7339df26ded", + "parent": "e7ded148d6e4a", + "root": null, + "order": 6, + "heading": "H0", + "label": "We Found John!", + "type": "FILE", + "class": "NOVEL", + "active": true, + "layout": "DOCUMENT", + "charCount": 189, + "wordCount": 37, + "paraCount": 1, + "cursorPos": 224, + "status": "s000003" + }, + { + "handle": "f6622b4617424", + "parent": null, + "root": null, + "order": 1, + "heading": "H0", + "label": "Characters", + "type": "ROOT", + "class": "CHARACTER", + "expanded": true, + "import": null + }, + { + "handle": "f7e2d9f330615", + "parent": "f6622b4617424", + "root": null, + "order": 0, + "heading": "H0", + "label": "Main Characters", + "type": "FOLDER", + "class": "CHARACTER", + "expanded": true, + "import": null + }, + { + "handle": "14298de4d9524", + "parent": "f7e2d9f330615", + "root": null, + "order": 0, + "heading": "H0", + "label": "John Smith", + "type": "FILE", + "class": "CHARACTER", + "active": true, + "layout": "NOTE", + "charCount": 49, + "wordCount": 9, + "paraCount": 1, + "cursorPos": 24, + "import": "i000008" + }, + { + "handle": "bb2c23b3c42cc", + "parent": "f7e2d9f330615", + "root": null, + "order": 1, + "heading": "H0", + "label": "Jane Smith", + "type": "FILE", + "class": "CHARACTER", + "active": true, + "layout": "NOTE", + "charCount": 55, + "wordCount": 9, + "paraCount": 1, + "cursorPos": 25, + "import": "i000009" + }, + { + "handle": "15c4492bd5107", + "parent": null, + "root": null, + "order": 2, + "heading": "H0", + "label": "Locations", + "type": "ROOT", + "class": "WORLD", + "expanded": true, + "import": null + }, + { + "handle": "b3e74dbc1f584", + "parent": "15c4492bd5107", + "root": null, + "order": 0, + "heading": "H0", + "label": "Earth", + "type": "FILE", + "class": "WORLD", + "active": true, + "layout": "NOTE", + "charCount": 76, + "wordCount": 15, + "paraCount": 1, + "cursorPos": 20, + "import": "i00000a" + }, + { + "handle": "f1471bef9f2ae", + "parent": "15c4492bd5107", + "root": null, + "order": 1, + "heading": "H0", + "label": "Space", + "type": "FILE", + "class": "WORLD", + "active": true, + "layout": "NOTE", + "charCount": 115, + "wordCount": 24, + "paraCount": 1, + "cursorPos": 133, + "import": "i000008" + }, + { + "handle": "5eaea4e8cdee8", + "parent": "15c4492bd5107", + "root": null, + "order": 2, + "heading": "H0", + "label": "Mars", + "type": "FILE", + "class": "WORLD", + "active": true, + "layout": "NOTE", + "charCount": 28, + "wordCount": 6, + "paraCount": 1, + "cursorPos": 45, + "import": "i000009" + }, + { + "handle": "6827118336ac1", + "parent": null, + "root": null, + "order": 3, + "heading": "H0", + "label": "Archive", + "type": "ROOT", + "class": "ARCHIVE", + "expanded": true, + "status": "s000000" + }, + { + "handle": "ae9bf3c3ea159", + "parent": "6827118336ac1", + "root": null, + "order": 0, + "heading": "H0", + "label": "Scenes", + "type": "FOLDER", + "class": "ARCHIVE", + "expanded": true, + "status": "s000000" + }, + { + "handle": "8a5deb88c0e97", + "parent": "ae9bf3c3ea159", + "root": null, + "order": 0, + "heading": "H0", + "label": "Old File", + "type": "FILE", + "class": "NOVEL", + "active": true, + "layout": "DOCUMENT", + "charCount": 314, + "wordCount": 55, + "paraCount": 1, + "cursorPos": 322, + "status": "s000003" + }, + { + "handle": "98acd8c76c93a", + "parent": null, + "root": null, + "order": 4, + "heading": "H0", + "label": "Trash", + "type": "ROOT", + "class": "TRASH", + "expanded": true, + "import": null + }, + { + "handle": "b8136a5a774a0", + "parent": "98acd8c76c93a", + "root": null, + "order": 0, + "heading": "H0", + "label": "Delete Me!", + "type": "FILE", + "class": "NOVEL", + "active": true, + "layout": "DOCUMENT", + "charCount": 30, + "wordCount": 6, + "paraCount": 1, + "cursorPos": 36, + "status": "s000000" + } +] \ No newline at end of file diff --git a/tests/reference/projectXML_ReadLegacy13.nwx b/tests/reference/projectXML_ReadLegacy13.nwx new file mode 100644 index 00000000..7fc4cfea --- /dev/null +++ b/tests/reference/projectXML_ReadLegacy13.nwx @@ -0,0 +1,155 @@ + + + + Sample Project + Sample Project + Jane Smith + Jay Doh + 5 + 10 + 1000 + + + True + en_GB + True + en_GB + 830 + 376 + + None + None + None + None + + + B + E + D + + + %title% + Chapter %chw%: %title% + %title% + Scene %ch%.%sc%: %title% + + + + New + Notes + Started + 1st Draft + 2nd Draft + 3rd Draft + Finished + + + None + Minor + Major + Main + + + + + + Novel + + + + Title Page + + + + Page + + + + Part One + + + + A Folder + + + + Chapter One + + + + Making a Scene + + + + Another Scene + + + + Interlude + + + + A Note on Structure + + + + Chapter Two + + + + We Found John! + + + + Characters + + + + Main Characters + + + + John Smith + + + + Jane Smith + + + + Locations + + + + Earth + + + + Space + + + + Mars + + + + Archive + + + + Scenes + + + + Old File + + + + Trash + + + + Delete Me! + + + 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 diff --git a/tests/test_core/test_core_document.py b/tests/test_core/test_core_document.py index ecc23e9c..761859ef 100644 --- a/tests/test_core/test_core_document.py +++ b/tests/test_core/test_core_document.py @@ -23,7 +23,7 @@ import os import pytest from mock import causeOSError -from tools import readFile, writeFile +from tools import C, buildTestProject, readFile, writeFile from novelwriter.enum import nwItemClass, nwItemLayout from novelwriter.core.project import NWProject @@ -31,14 +31,12 @@ from novelwriter.core.document import NWDoc @pytest.mark.core -def testCoreDocument_LoadSave(monkeypatch, mockGUI, nwMinimal): +def testCoreDocument_LoadSave(monkeypatch, mockGUI, fncDir, mockRnd): """Test loading and saving a document with the NWDoc class. """ theProject = NWProject(mockGUI) - assert theProject.openProject(nwMinimal) is True - assert theProject.projPath == nwMinimal - - sHandle = "8c659a11cd429" + mockRnd.reset() + buildTestProject(theProject, fncDir) # Read Document # ============= @@ -49,25 +47,23 @@ def testCoreDocument_LoadSave(monkeypatch, mockGUI, nwMinimal): assert theDoc.readDocument() is None # Non-existent handle - theDoc = NWDoc(theProject, "0000000000000") + theDoc = NWDoc(theProject, C.hInvalid) assert theDoc.readDocument() is None assert theDoc._currHash is None # Cause open() to fail while loading with monkeypatch.context() as mp: mp.setattr("builtins.open", causeOSError) - theDoc = NWDoc(theProject, sHandle) + theDoc = NWDoc(theProject, C.hSceneDoc) assert theDoc.readDocument() is None assert theDoc.getError() == "OSError: Mock OSError" # Load the text - theDoc = NWDoc(theProject, sHandle) + theDoc = NWDoc(theProject, C.hSceneDoc) assert theDoc.readDocument() == "### New Scene\n\n" # Try to open a new (non-existent) file - nHandle = theProject.tree.findRoot(nwItemClass.NOVEL) - assert nHandle is not None - xHandle = theProject.newFile("New File", nHandle) + xHandle = theProject.newFile("New File", C.hNovelRoot) theDoc = NWDoc(theProject, xHandle) assert bool(theDoc) is True assert repr(theDoc) == f"" @@ -86,10 +82,10 @@ def testCoreDocument_LoadSave(monkeypatch, mockGUI, nwMinimal): assert theDoc.writeDocument(theText) # Check file content - docPath = os.path.join(nwMinimal, "content", xHandle+".nwd") + docPath = os.path.join(fncDir, "content", xHandle+".nwd") assert readFile(docPath) == ( "%%~name: New File\n" - f"%%~path: a508bb932959c/{xHandle}\n" + f"%%~path: {C.hNovelRoot}/{xHandle}\n" "%%~kind: NOVEL/DOCUMENT\n" "### Test File\n\n" "Text ...\n\n" @@ -153,16 +149,15 @@ def testCoreDocument_LoadSave(monkeypatch, mockGUI, nwMinimal): @pytest.mark.core -def testCoreDocument_Methods(mockGUI, nwMinimal): +def testCoreDocument_Methods(mockGUI, fncDir, mockRnd): """Test other methods of the NWDoc class. """ theProject = NWProject(mockGUI) - assert theProject.openProject(nwMinimal) - assert theProject.projPath == nwMinimal + mockRnd.reset() + buildTestProject(theProject, fncDir) - sHandle = "8c659a11cd429" - theDoc = NWDoc(theProject, sHandle) - docPath = os.path.join(nwMinimal, "content", sHandle+".nwd") + theDoc = NWDoc(theProject, C.hSceneDoc) + docPath = os.path.join(fncDir, "content", C.hSceneDoc+".nwd") assert theDoc.readDocument() == "### New Scene\n\n" @@ -171,12 +166,12 @@ def testCoreDocument_Methods(mockGUI, nwMinimal): # Check the item assert theDoc.getCurrentItem() is not None - assert theDoc.getCurrentItem().itemHandle == sHandle + assert theDoc.getCurrentItem().itemHandle == C.hSceneDoc # Check the meta theName, theParent, theClass, theLayout = theDoc.getMeta() assert theName == "New Scene" - assert theParent == "a6d311a93600a" + assert theParent == C.hChapterDir assert theClass == nwItemClass.NOVEL assert theLayout == nwItemLayout.DOCUMENT @@ -184,7 +179,7 @@ def testCoreDocument_Methods(mockGUI, nwMinimal): assert theDoc.writeDocument("%%~ stuff\n### Test File\n\nText ...\n\n") assert readFile(docPath) == ( "%%~name: New Scene\n" - f"%%~path: a6d311a93600a/{sHandle}\n" + f"%%~path: {C.hChapterDir}/{C.hSceneDoc}\n" "%%~kind: NOVEL/DOCUMENT\n" "%%~ stuff\n" "### Test File\n\n" diff --git a/tests/test_core/test_core_item.py b/tests/test_core/test_core_item.py index e7a72336..714d6d57 100644 --- a/tests/test_core/test_core_item.py +++ b/tests/test_core/test_core_item.py @@ -21,20 +21,22 @@ along with this program. If not, see . import pytest -from lxml import etree - from PyQt5.QtGui import QIcon +from tools import C, buildTestProject + from novelwriter.core.item import NWItem from novelwriter.core.project import NWProject from novelwriter.enum import nwItemClass, nwItemType, nwItemLayout @pytest.mark.core -def testCoreItem_Setters(mockGUI, mockRnd): +def testCoreItem_Setters(mockGUI, mockRnd, fncDir): """Test all the simple setters for the NWItem class. """ theProject = NWProject(mockGUI) + mockRnd.reset() + buildTestProject(theProject, fncDir) theItem = NWItem(theProject) statusKeys = ["s000000", "s000001", "s000002", "s000003"] @@ -153,7 +155,7 @@ def testCoreItem_Setters(mockGUI, mockRnd): theItem.setCharCount(None) assert theItem.charCount == 0 theItem.setCharCount("1") - assert theItem.charCount == 1 + assert theItem.charCount == 0 theItem.setCharCount(1) assert theItem.charCount == 1 @@ -161,7 +163,7 @@ def testCoreItem_Setters(mockGUI, mockRnd): theItem.setWordCount(None) assert theItem.wordCount == 0 theItem.setWordCount("1") - assert theItem.wordCount == 1 + assert theItem.wordCount == 0 theItem.setWordCount(1) assert theItem.wordCount == 1 @@ -169,7 +171,7 @@ def testCoreItem_Setters(mockGUI, mockRnd): theItem.setParaCount(None) assert theItem.paraCount == 0 theItem.setParaCount("1") - assert theItem.paraCount == 1 + assert theItem.paraCount == 0 theItem.setParaCount(1) assert theItem.paraCount == 1 @@ -177,7 +179,7 @@ def testCoreItem_Setters(mockGUI, mockRnd): theItem.setCursorPos(None) assert theItem.cursorPos == 0 theItem.setCursorPos("1") - assert theItem.cursorPos == 1 + assert theItem.cursorPos == 0 theItem.setCursorPos(1) assert theItem.cursorPos == 1 @@ -190,10 +192,12 @@ def testCoreItem_Setters(mockGUI, mockRnd): @pytest.mark.core -def testCoreItem_Methods(mockGUI): +def testCoreItem_Methods(mockGUI, mockRnd, fncDir): """Test the simple methods of the NWItem class. """ theProject = NWProject(mockGUI) + mockRnd.reset() + buildTestProject(theProject, fncDir) theItem = NWItem(theProject) # Describe Me @@ -250,15 +254,15 @@ def testCoreItem_Methods(mockGUI): # ============= theItem.setType("FILE") - theItem.setStatus("Note") - theItem.setImport("Minor") + theItem.setStatus(C.sNote) + theItem.setImport(C.iMinor) theItem.setClass("NOVEL") stT, stI = theItem.getImportStatus() assert stT == "Note" assert isinstance(stI, QIcon) - theItem.setImportStatus("Draft") + theItem.setImportStatus(C.sDraft) stT, stI = theItem.getImportStatus() assert stT == "Draft" @@ -267,7 +271,7 @@ def testCoreItem_Methods(mockGUI): assert stT == "Minor" assert isinstance(stI, QIcon) - theItem.setImportStatus("Major") + theItem.setImportStatus(C.iMajor) stT, stI = theItem.getImportStatus() assert stT == "Major" @@ -491,254 +495,199 @@ def testCoreItem_ClassDefaults(mockGUI): @pytest.mark.core -def testCoreItem_XMLPackUnpack(mockGUI, caplog, mockRnd): - """Test packing and unpacking XML objects for the NWItem class. +def testCoreItem_PackUnpack(mockGUI, caplog, mockRnd): + """Test packing and unpacking entries for the NWItem class. """ theProject = NWProject(mockGUI) - nwXML = etree.Element("novelWriterXML") + theProject.data.itemStatus.write(None, "New", (100, 100, 100)) + theProject.data.itemImport.write(None, "New", (100, 100, 100)) - statusKeys = ["s000000", "s000001", "s000002", "s000003"] - importKeys = ["i000004", "i000005", "i000006", "i000007"] + # Invalid + theItem = NWItem(theProject) + assert theItem.unpack({}) is False # File - # ==== - theItem = NWItem(theProject) - theItem.setHandle("0123456789abc") - theItem.setParent("0123456789abc") - theItem.setRoot("0123456789abc") - theItem.setOrder(1) - theItem.setName("A Name") - theItem.setClass("NOVEL") - theItem.setType("FILE") - theItem.setImport(importKeys[3]) - theItem.setLayout("NOTE") - theItem.setActive(False) - theItem.setParaCount(3) - theItem.setWordCount(5) - theItem.setCharCount(7) - theItem.setCursorPos(11) + assert theItem.unpack({ + "label": "A File", + "handle": "0000000000003", + "parent": "0000000000002", + "root": "0000000000001", + "order": 1, + "type": "FILE", + "class": "NOVEL", + "layout": "DOCUMENT", + "expanded": True, + "status": None, + "import": None, + "heading": "H1", + "charCount": 100, + "wordCount": 20, + "paraCount": 2, + "cursorPos": 50, + "active": False, + }) is True - # Pack - xContent = etree.SubElement(nwXML, "content") - theItem.packXML(xContent) - assert etree.tostring(xContent, pretty_print=False, encoding="utf-8") == ( - b'' - b'A Name' - b'' - ) % bytes(importKeys[3], encoding="utf8") - - # Unpack - theItem = NWItem(theProject) - assert theItem.unpackXML(xContent[0]) - assert theItem.itemHandle == "0123456789abc" - assert theItem.itemParent == "0123456789abc" - assert theItem.itemRoot == "0123456789abc" + assert theItem.itemName == "A File" + assert theItem.itemHandle == "0000000000003" + assert theItem.itemParent == "0000000000002" + assert theItem.itemRoot == "0000000000001" assert theItem.itemOrder == 1 + assert theItem.itemType == nwItemType.FILE + assert theItem.itemClass == nwItemClass.NOVEL + assert theItem.itemLayout == nwItemLayout.DOCUMENT + assert theItem.itemStatus == "s000000" + assert theItem.itemImport == "i000001" assert theItem.isActive is False - assert theItem.paraCount == 3 - assert theItem.wordCount == 5 - assert theItem.charCount == 7 - assert theItem.cursorPos == 11 - assert theItem.itemClass == nwItemClass.NOVEL - assert theItem.itemType == nwItemType.FILE - assert theItem.itemLayout == nwItemLayout.NOTE - assert theItem.itemStatus == statusKeys[0] # Was None, should now be default - assert theItem.itemImport == importKeys[3] - - # Folder - # ====== - - theItem = NWItem(theProject) - theItem.setHandle("0123456789abc") - theItem.setParent("0123456789abc") - theItem.setRoot("0123456789abc") - theItem.setOrder(1) - theItem.setName("A Name") - theItem.setClass("NOVEL") - theItem.setType("FOLDER") - theItem.setStatus(statusKeys[1]) - theItem.setLayout("NOTE") - theItem.setExpanded(True) - theItem.setActive(False) - theItem.setParaCount(3) - theItem.setWordCount(5) - theItem.setCharCount(7) - theItem.setCursorPos(11) - - # Pack - xContent = etree.SubElement(nwXML, "content") - theItem.packXML(xContent) - assert etree.tostring(xContent, pretty_print=False, encoding="utf-8") == ( - b'' - b'A Name' - b'' - ) % bytes(statusKeys[1], encoding="utf8") - - # Unpack - theItem = NWItem(theProject) - assert theItem.unpackXML(xContent[0]) - assert theItem.itemHandle == "0123456789abc" - assert theItem.itemParent == "0123456789abc" - assert theItem.itemRoot == "0123456789abc" - assert theItem.itemOrder == 1 assert theItem.isExpanded is True - assert theItem.isActive is True - assert theItem.paraCount == 0 - assert theItem.wordCount == 0 - assert theItem.charCount == 0 - assert theItem.cursorPos == 0 - assert theItem.itemClass == nwItemClass.NOVEL - assert theItem.itemType == nwItemType.FOLDER - assert theItem.itemLayout == nwItemLayout.NO_LAYOUT - assert theItem.itemStatus == statusKeys[1] - assert theItem.itemImport == importKeys[0] # Was None, should now be default - - # Errors - # ====== - - # Not an Item - mockXml = etree.SubElement(nwXML, "stuff") - assert theItem.unpackXML(mockXml) is False - - # Item without Handle - mockXml = etree.SubElement(nwXML, "item", attrib={"stuff": "nah"}) - assert theItem.unpackXML(mockXml) is False - - # Item with Invalid SubElement is Accepted w/Error - mockXml = etree.SubElement(nwXML, "item", attrib={"handle": "0123456789abc"}) - xParam = etree.SubElement(mockXml, "invalid") - xParam.text = "stuff" - caplog.clear() - assert theItem.unpackXML(mockXml) is True - assert "Unknown tag 'invalid'" in caplog.text - - # Pack Valid Item - mockXml = etree.SubElement(nwXML, "group") - theItem._subPack(mockXml, "subGroup", {"one": "two"}, "value", False) - assert etree.tostring(mockXml, pretty_print=False, encoding="utf-8") == ( - b"value" - ) - - # Pack Not Allowed None - mockXml = etree.SubElement(nwXML, "group") - assert theItem._subPack(mockXml, "subGroup", {}, None, False) is None - assert theItem._subPack(mockXml, "subGroup", {}, "None", False) is None - assert etree.tostring(mockXml, pretty_print=False, encoding="utf-8") == ( - b"" - ) - -# END Test testCoreItem_XMLPackUnpack - - -@pytest.mark.core -def testCoreItem_ConvertFromFmt12(mockGUI): - """Test the setter for all the nwItemLayout values for the NWItem - class using the class names that were present in file format 1.2. - """ - theProject = NWProject(mockGUI) - theItem = NWItem(theProject) - - # Deprecated Layouts - theItem.setLayout("TITLE") - assert theItem.itemLayout == nwItemLayout.DOCUMENT - theItem.setLayout("PAGE") - assert theItem.itemLayout == nwItemLayout.DOCUMENT - theItem.setLayout("BOOK") - assert theItem.itemLayout == nwItemLayout.DOCUMENT - theItem.setLayout("PARTITION") - assert theItem.itemLayout == nwItemLayout.DOCUMENT - theItem.setLayout("UNNUMBERED") - assert theItem.itemLayout == nwItemLayout.DOCUMENT - theItem.setLayout("CHAPTER") - assert theItem.itemLayout == nwItemLayout.DOCUMENT - theItem.setLayout("SCENE") - assert theItem.itemLayout == nwItemLayout.DOCUMENT - theItem.setLayout("MUMBOJUMBO") - assert theItem.itemLayout == nwItemLayout.NO_LAYOUT - -# END Test testCoreItem_ConvertFromFmt12 - - -@pytest.mark.core -def testCoreItem_ConvertFromFmt13(mockGUI): - """Test packing and unpacking XML objects for the NWItem class from - format version 1.3 - """ - theProject = NWProject(mockGUI) - - # Make Version 1.3 XML - nwXML = etree.Element("novelWriterXML") - xContent = etree.SubElement(nwXML, "content") - - # Folder - xPack = etree.SubElement(xContent, "item", attrib={ - "handle": "a000000000001", - "order": "1", - "parent": "b000000000001", - }) - NWItem._subPack(xPack, "name", text="Folder") - NWItem._subPack(xPack, "type", text="FOLDER") - NWItem._subPack(xPack, "class", text="NOVEL") - NWItem._subPack(xPack, "status", text="New") - NWItem._subPack(xPack, "expanded", text="True") - - # Unpack Folder - theItem = NWItem(theProject) - theItem.unpackXML(xContent[0]) - assert theItem.itemHandle == "a000000000001" - assert theItem.itemParent == "b000000000001" - assert theItem.itemOrder == 1 - assert theItem.isExpanded is True - assert theItem.isActive is True - assert theItem.charCount == 0 - assert theItem.wordCount == 0 - assert theItem.paraCount == 0 - assert theItem.cursorPos == 0 - assert theItem.itemClass == nwItemClass.NOVEL - assert theItem.itemType == nwItemType.FOLDER - assert theItem.itemLayout == nwItemLayout.NO_LAYOUT - - # File - xPack = etree.SubElement(xContent, "item", attrib={ - "handle": "c000000000001", - "order": "2", - "parent": "a000000000001", - }) - NWItem._subPack(xPack, "name", text="Scene") - NWItem._subPack(xPack, "type", text="FILE") - NWItem._subPack(xPack, "class", text="NOVEL") - NWItem._subPack(xPack, "status", text="New") - NWItem._subPack(xPack, "exported", text="True") - NWItem._subPack(xPack, "layout", text="DOCUMENT") - NWItem._subPack(xPack, "charCount", text="600") - NWItem._subPack(xPack, "wordCount", text="100") - NWItem._subPack(xPack, "paraCount", text="6") - NWItem._subPack(xPack, "cursorPos", text="50") - - # Unpack File - theItem = NWItem(theProject) - theItem.unpackXML(xContent[1]) - assert theItem.itemHandle == "c000000000001" - assert theItem.itemParent == "a000000000001" - assert theItem.itemOrder == 2 - assert theItem.isExpanded is False - assert theItem.isActive is True - assert theItem.charCount == 600 - assert theItem.wordCount == 100 - assert theItem.paraCount == 6 + assert theItem.mainHeading == "H1" + assert theItem.charCount == 100 + assert theItem.wordCount == 20 + assert theItem.paraCount == 2 assert theItem.cursorPos == 50 + + assert theItem.pack() == { + "name": "A File", + "itemAttr": { + "handle": "0000000000003", + "parent": "0000000000002", + "root": "0000000000001", + "order": "1", + "type": "FILE", + "class": "NOVEL", + "layout": "DOCUMENT", + }, + "metaAttr": { + "expanded": "True", + "heading": "H1", + "charCount": "100", + "wordCount": "20", + "paraCount": "2", + "cursorPos": "50", + }, + "nameAttr": { + "status": "s000000", + "import": "i000001", + "active": "False", + } + } + + # Folder + theItem = NWItem(theProject) + assert theItem.unpack({ + "label": "A Folder", + "handle": "0000000000003", + "parent": "0000000000002", + "root": "0000000000001", + "order": 1, + "type": "FOLDER", + "class": "NOVEL", + "layout": "DOCUMENT", + "expanded": True, + "status": "", + "import": "", + "heading": "H1", + "charCount": 100, + "wordCount": 20, + "paraCount": 2, + "cursorPos": 50, + "active": True, + }) is True + + assert theItem.itemName == "A Folder" + assert theItem.itemHandle == "0000000000003" + assert theItem.itemParent == "0000000000002" + assert theItem.itemRoot == "0000000000001" + assert theItem.itemOrder == 1 + assert theItem.itemType == nwItemType.FOLDER assert theItem.itemClass == nwItemClass.NOVEL - assert theItem.itemType == nwItemType.FILE - assert theItem.itemLayout == nwItemLayout.DOCUMENT + assert theItem.itemLayout == nwItemLayout.NO_LAYOUT + assert theItem.itemStatus == "s000000" + assert theItem.itemImport == "i000001" + assert theItem.isActive is False + assert theItem.isExpanded is True + assert theItem.mainHeading == "H0" + assert theItem.charCount == 0 + assert theItem.wordCount == 0 + assert theItem.paraCount == 0 + assert theItem.cursorPos == 0 - # Deprecated Type - theItem.setType("TRASH") + assert theItem.pack() == { + "name": "A Folder", + "itemAttr": { + "handle": "0000000000003", + "parent": "0000000000002", + "root": "0000000000001", + "order": "1", + "type": "FOLDER", + "class": "NOVEL", + }, + "metaAttr": { + "expanded": "True", + }, + "nameAttr": { + "status": "s000000", + "import": "i000001", + } + } + + # Root + theItem = NWItem(theProject) + assert theItem.unpack({ + "label": "A Novel", + "handle": "0000000000003", + "parent": "0000000000002", + "root": "0000000000001", + "order": 1, + "type": "ROOT", + "class": "NOVEL", + "layout": "DOCUMENT", + "expanded": True, + "status": None, + "import": None, + "heading": "H1", + "charCount": 100, + "wordCount": 20, + "paraCount": 2, + "cursorPos": 50, + "active": True, + }) is True + + assert theItem.itemName == "A Novel" + assert theItem.itemHandle == "0000000000003" + assert theItem.itemParent is None + assert theItem.itemRoot == "0000000000003" + assert theItem.itemOrder == 1 assert theItem.itemType == nwItemType.ROOT + assert theItem.itemClass == nwItemClass.NOVEL + assert theItem.itemLayout == nwItemLayout.NO_LAYOUT + assert theItem.itemStatus == "s000000" + assert theItem.itemImport == "i000001" + assert theItem.isActive is False + assert theItem.isExpanded is True + assert theItem.mainHeading == "H0" + assert theItem.charCount == 0 + assert theItem.wordCount == 0 + assert theItem.paraCount == 0 + assert theItem.cursorPos == 0 -# END Test testCoreItem_ConvertFromFmt13 + assert theItem.pack() == { + "name": "A Novel", + "itemAttr": { + "handle": "0000000000003", + "parent": "None", + "root": "0000000000003", + "order": "1", + "type": "ROOT", + "class": "NOVEL", + }, + "metaAttr": { + "expanded": "True", + }, + "nameAttr": { + "status": "s000000", + "import": "i000001", + } + } + +# END Test testCoreItem_PackUnpack diff --git a/tests/test_core/test_core_project.py b/tests/test_core/test_core_project.py index 5a95295b..02ce6c27 100644 --- a/tests/test_core/test_core_project.py +++ b/tests/test_core/test_core_project.py @@ -20,14 +20,14 @@ along with this program. If not, see . """ import os +import shutil import pytest -from lxml import etree from shutil import copyfile from zipfile import ZipFile -from tools import cmpFiles, writeFile, readFile, buildTestProject, XML_IGNORE, C from mock import causeOSError +from tools import cmpFiles, writeFile, readFile, buildTestProject, XML_IGNORE, C from novelwriter.enum import nwItemClass, nwItemType, nwItemLayout from novelwriter.common import formatTimeStamp @@ -37,6 +37,7 @@ from novelwriter.core.index import NWIndex from novelwriter.core.project import NWProject from novelwriter.core.options import OptionState from novelwriter.core.document import NWDoc +from novelwriter.core.projectxml import ProjectXMLReader, ProjectXMLWriter, XMLReadState @pytest.mark.core @@ -202,7 +203,7 @@ def testCoreProject_NewSampleA(fncDir, tmpConf, mockGUI, tmpDir): assert theProject.newProject(projData) is True assert theProject.openProject(fncDir) is True - assert theProject.projName == "Sample Project" + assert theProject.data.name == "Sample Project" assert theProject.saveProject() is True assert theProject.closeProject() is True os.unlink(dstSample) @@ -236,7 +237,7 @@ def testCoreProject_NewSampleB(monkeypatch, fncDir, tmpConf, mockGUI, tmpDir): monkeypatch.setattr(nwFiles, "PROJ_FILE", "nwProject.nwx") assert theProject.newProject(projData) is True assert theProject.openProject(fncDir) is True - assert theProject.projName == "Sample Project" + assert theProject.data.name == "Sample Project" assert theProject.saveProject() is True assert theProject.closeProject() is True @@ -264,14 +265,14 @@ def testCoreProject_NewRoot(fncDir, outDir, refDir, mockGUI, mockRnd): assert theProject.closeProject() is True assert theProject.openProject(projFile) is True - assert theProject.newRoot(nwItemClass.NOVEL) == "0000000000020" - assert theProject.newRoot(nwItemClass.PLOT) == "0000000000021" - assert theProject.newRoot(nwItemClass.CHARACTER) == "0000000000022" - assert theProject.newRoot(nwItemClass.WORLD) == "0000000000023" - assert theProject.newRoot(nwItemClass.TIMELINE) == "0000000000024" - assert theProject.newRoot(nwItemClass.OBJECT) == "0000000000025" - assert theProject.newRoot(nwItemClass.CUSTOM) == "0000000000026" - assert theProject.newRoot(nwItemClass.CUSTOM) == "0000000000027" + assert theProject.newRoot(nwItemClass.NOVEL) == "0000000000010" + assert theProject.newRoot(nwItemClass.PLOT) == "0000000000011" + assert theProject.newRoot(nwItemClass.CHARACTER) == "0000000000012" + assert theProject.newRoot(nwItemClass.WORLD) == "0000000000013" + assert theProject.newRoot(nwItemClass.TIMELINE) == "0000000000014" + assert theProject.newRoot(nwItemClass.OBJECT) == "0000000000015" + assert theProject.newRoot(nwItemClass.CUSTOM) == "0000000000016" + assert theProject.newRoot(nwItemClass.CUSTOM) == "0000000000017" assert theProject.projChanged is True assert theProject.saveProject() is True @@ -282,23 +283,23 @@ def testCoreProject_NewRoot(fncDir, outDir, refDir, mockGUI, mockRnd): assert theProject.projChanged is False # Delete the new items - assert theProject.removeItem("0000000000020") is True - assert theProject.removeItem("0000000000021") is True - assert theProject.removeItem("0000000000022") is True - assert theProject.removeItem("0000000000023") is True - assert theProject.removeItem("0000000000024") is True - assert theProject.removeItem("0000000000025") is True - assert theProject.removeItem("0000000000026") is True - assert theProject.removeItem("0000000000027") is True + assert theProject.removeItem("0000000000010") is True + assert theProject.removeItem("0000000000011") is True + assert theProject.removeItem("0000000000012") is True + assert theProject.removeItem("0000000000013") is True + assert theProject.removeItem("0000000000014") is True + assert theProject.removeItem("0000000000015") is True + assert theProject.removeItem("0000000000016") is True + assert theProject.removeItem("0000000000017") is True - assert "0000000000020" not in theProject.tree - assert "0000000000021" not in theProject.tree - assert "0000000000022" not in theProject.tree - assert "0000000000023" not in theProject.tree - assert "0000000000024" not in theProject.tree - assert "0000000000025" not in theProject.tree - assert "0000000000026" not in theProject.tree - assert "0000000000027" not in theProject.tree + assert "0000000000010" not in theProject.tree + assert "0000000000011" not in theProject.tree + assert "0000000000012" not in theProject.tree + assert "0000000000013" not in theProject.tree + assert "0000000000014" not in theProject.tree + assert "0000000000015" not in theProject.tree + assert "0000000000016" not in theProject.tree + assert "0000000000017" not in theProject.tree # END Test testCoreProject_NewRoot @@ -325,26 +326,26 @@ def testCoreProject_NewFileFolder(monkeypatch, fncDir, outDir, refDir, mockGUI, assert theProject.newFile("New File", "1234567890abc") is None # Add files properly - assert theProject.newFolder("Stuff", C.hNovelRoot) == "0000000000020" - assert theProject.newFile("Hello", "0000000000020") == "0000000000021" - assert theProject.newFile("Jane", C.hCharRoot) == "0000000000022" + assert theProject.newFolder("Stuff", C.hNovelRoot) == "0000000000010" + assert theProject.newFile("Hello", "0000000000010") == "0000000000011" + assert theProject.newFile("Jane", C.hCharRoot) == "0000000000012" - assert "0000000000020" in theProject.tree - assert "0000000000021" in theProject.tree - assert "0000000000022" in theProject.tree + assert "0000000000010" in theProject.tree + assert "0000000000011" in theProject.tree + assert "0000000000012" in theProject.tree # Write to file, failed assert theProject.writeNewFile("blabla", 1, True) is False # Not a handle - assert theProject.writeNewFile("0000000000020", 1, True) is False # Not a file + assert theProject.writeNewFile("0000000000010", 1, True) is False # Not a file assert theProject.writeNewFile(C.hTitlePage, 1, True) is False # Already has content # Write to file, success - assert theProject.writeNewFile("0000000000021", 2, True) is True - assert NWDoc(theProject, "0000000000021").readDocument() == "## Hello\n\n" + assert theProject.writeNewFile("0000000000011", 2, True) is True + assert NWDoc(theProject, "0000000000011").readDocument() == "## Hello\n\n" # Write to file with additional text, success - assert theProject.writeNewFile("0000000000022", 1, False, "Hi Jane\n\n") is True - assert NWDoc(theProject, "0000000000022").readDocument() == "# Jane\n\nHi Jane\n\n" + assert theProject.writeNewFile("0000000000012", 1, False, "Hi Jane\n\n") is True + assert NWDoc(theProject, "0000000000012").readDocument() == "# Jane\n\nHi Jane\n\n" # Save, close and check assert theProject.projChanged is True @@ -357,23 +358,23 @@ def testCoreProject_NewFileFolder(monkeypatch, fncDir, outDir, refDir, mockGUI, # Delete new file, but block access with monkeypatch.context() as mp: mp.setattr("os.unlink", causeOSError) - assert theProject.removeItem("0000000000021") is False - assert "0000000000021" in theProject.tree + assert theProject.removeItem("0000000000011") is False + assert "0000000000011" in theProject.tree # Delete new files and folders - assert os.path.isfile(os.path.join(fncDir, "content", "0000000000022.nwd")) - assert os.path.isfile(os.path.join(fncDir, "content", "0000000000021.nwd")) + assert os.path.isfile(os.path.join(fncDir, "content", "0000000000012.nwd")) + assert os.path.isfile(os.path.join(fncDir, "content", "0000000000011.nwd")) - assert theProject.removeItem("0000000000022") is True - assert theProject.removeItem("0000000000021") is True - assert theProject.removeItem("0000000000020") is True + assert theProject.removeItem("0000000000012") is True + assert theProject.removeItem("0000000000011") is True + assert theProject.removeItem("0000000000010") is True - assert not os.path.isfile(os.path.join(fncDir, "content", "0000000000022.nwd")) - assert not os.path.isfile(os.path.join(fncDir, "content", "0000000000021.nwd")) + assert not os.path.isfile(os.path.join(fncDir, "content", "0000000000012.nwd")) + assert not os.path.isfile(os.path.join(fncDir, "content", "0000000000011.nwd")) - assert "0000000000020" not in theProject.tree - assert "0000000000021" not in theProject.tree - assert "0000000000022" not in theProject.tree + assert "0000000000010" not in theProject.tree + assert "0000000000011" not in theProject.tree + assert "0000000000012" not in theProject.tree assert theProject.closeProject() is True @@ -381,139 +382,90 @@ def testCoreProject_NewFileFolder(monkeypatch, fncDir, outDir, refDir, mockGUI, @pytest.mark.core -def testCoreProject_Open(monkeypatch, nwMinimal, mockGUI): +def testCoreProject_Open(monkeypatch, caplog, mockGUI, fncDir, mockRnd): """Test opening a project. """ theProject = NWProject(mockGUI) + mockRnd.reset() + buildTestProject(theProject, fncDir) # Rename the project file to check handling - rName = os.path.join(nwMinimal, nwFiles.PROJ_FILE) - wName = os.path.join(nwMinimal, nwFiles.PROJ_FILE+"_sdfghj") + rName = os.path.join(fncDir, nwFiles.PROJ_FILE) + wName = os.path.join(fncDir, nwFiles.PROJ_FILE+"_sdfghj") os.rename(rName, wName) - assert theProject.openProject(nwMinimal) is False + assert theProject.openProject(fncDir) is False os.rename(wName, rName) # Fail on folder structure check with monkeypatch.context() as mp: mp.setattr("os.mkdir", causeOSError) - assert theProject.openProject(nwMinimal) is False + shutil.rmtree(os.path.join(fncDir, "meta")) + assert theProject.openProject(fncDir) is False # Fail on lock file - theProject.setProjectPath(nwMinimal) + theProject.setProjectPath(fncDir) assert theProject._writeLockFile() - assert theProject.openProject(nwMinimal) is False + assert theProject.openProject(fncDir) is False # Fail to read lockfile (which still opens the project) with monkeypatch.context() as mp: mp.setattr("builtins.open", causeOSError) - assert theProject.openProject(nwMinimal) is True + caplog.clear() + assert theProject.openProject(fncDir) is True + assert "Failed to check lock file" in caplog.text assert theProject.closeProject() # Force open with lockfile - theProject.setProjectPath(nwMinimal) + theProject.setProjectPath(fncDir) assert theProject._writeLockFile() - assert theProject.openProject(nwMinimal, overrideLock=True) is True + assert theProject.openProject(fncDir, overrideLock=True) is True assert theProject.closeProject() - # Make a junk XML file - oName = os.path.join(nwMinimal, nwFiles.PROJ_FILE[:-3]+"orig") - bName = os.path.join(nwMinimal, nwFiles.PROJ_FILE[:-3]+"bak") - os.rename(rName, oName) - writeFile(rName, "stuff") - assert theProject.openProject(nwMinimal) is False + # Not a novelwriter XML file + with monkeypatch.context() as mp: + mp.setattr(ProjectXMLReader, "read", lambda *a: False) + mp.setattr(ProjectXMLReader, "state", property(lambda *a: XMLReadState.NOT_NWX_FILE)) + assert theProject.openProject(fncDir) is False + assert "Project file does not appear" in mockGUI.lastAlert - # Also write a jun XML backup file - writeFile(bName, "stuff") - assert theProject.openProject(nwMinimal) is False + # Unknown project file version + with monkeypatch.context() as mp: + mp.setattr(ProjectXMLReader, "read", lambda *a: False) + mp.setattr(ProjectXMLReader, "state", property(lambda *a: XMLReadState.UNKNOWN_VERSION)) + assert theProject.openProject(fncDir) is False + assert "Unknown or unsupported novelWriter project file" in mockGUI.lastAlert - # Wrong root item - writeFile(rName, "\n") - assert theProject.openProject(nwMinimal) is False + # Other parse error + with monkeypatch.context() as mp: + mp.setattr(ProjectXMLReader, "read", lambda *a: False) + mp.setattr(ProjectXMLReader, "state", property(lambda *a: XMLReadState.CANNOT_PARSE)) + assert theProject.openProject(fncDir) is False + assert "Failed to parse project xml" in mockGUI.lastAlert - # Wrong file version - writeFile(rName, ( - "\n" - "\n" - "\n" - )) - mockGUI.askResponse = False - assert theProject.openProject(nwMinimal) is False - mockGUI.undo() + # Won't convert legacy file + with monkeypatch.context() as mp: + mp.setattr(ProjectXMLReader, "state", property(lambda *a: XMLReadState.WAS_LEGACY)) + mockGUI.askResponse = False + assert theProject.openProject(fncDir) is False + assert "The file format of your project is about to be" in mockGUI.lastQuestion[1] + mockGUI.askResponse = True - # Future file version - writeFile(rName, ( - "\n" - "\n" - "\n" - )) - assert theProject.openProject(nwMinimal) is False - - # Update file version - writeFile(rName, ( - "\n" - "\n" - "\n" - )) - mockGUI.askResponse = False - assert theProject.openProject(nwMinimal) is False - assert mockGUI.lastQuestion[0] == "File Version" - mockGUI.undo() - - # Larger hex version - writeFile(rName, ( - "\n" - "\n" - "\n" - ) % theProject.FILE_VERSION) - mockGUI.askResponse = False - assert theProject.openProject(nwMinimal) is False - assert mockGUI.lastQuestion[0] == "Version Conflict" - mockGUI.undo() - - # Test skipping XML entries - writeFile(rName, ( - "\n" - "\n" - "\n" - "\n" - "\n" - )) - assert theProject.openProject(nwMinimal) is True - assert theProject.closeProject() - - # Clean up XML files - os.unlink(rName) - os.unlink(bName) - os.rename(oName, rName) + # Won't convert legacy file + with monkeypatch.context() as mp: + mp.setattr(ProjectXMLReader, "hexVersion", property(lambda *a: "0x99999999")) + mockGUI.askResponse = False + assert theProject.openProject(fncDir) is False + assert "This project was saved by a newer version" in mockGUI.lastQuestion[1] + mockGUI.askResponse = True # Add some legacy stuff that cannot be removed with monkeypatch.context() as mp: mp.setattr(theProject, "_legacyDataFolder", causeOSError) - os.mkdir(os.path.join(nwMinimal, "data_0")) - writeFile(os.path.join(nwMinimal, "data_0", "123456789abc_main.nwd"), "stuff") - writeFile(os.path.join(nwMinimal, "data_0", "123456789abc_main.bak"), "stuff") + os.mkdir(os.path.join(fncDir, "data_0")) + writeFile(os.path.join(fncDir, "data_0", "123456789abc_main.nwd"), "stuff") + writeFile(os.path.join(fncDir, "data_0", "123456789abc_main.bak"), "stuff") mockGUI.clear() - assert theProject.openProject(nwMinimal) is True + assert theProject.openProject(fncDir) is True assert "There was an error updating the project." in mockGUI.lastAlert assert theProject.closeProject() @@ -522,56 +474,31 @@ def testCoreProject_Open(monkeypatch, nwMinimal, mockGUI): @pytest.mark.core -def testCoreProject_Save(monkeypatch, nwMinimal, mockGUI, refDir): +def testCoreProject_Save(monkeypatch, mockGUI, mockRnd, fncDir, refDir): """Test saving a project. """ theProject = NWProject(mockGUI) - testFile = os.path.join(nwMinimal, "nwProject.nwx") - backFile = os.path.join(nwMinimal, "nwProject.bak") - compFile = os.path.join(refDir, os.path.pardir, "minimal", "nwProject.nwx") # Nothing to save assert theProject.saveProject() is False - # Open test project - assert theProject.openProject(nwMinimal) + mockRnd.reset() + buildTestProject(theProject, fncDir) # Fail on folder structure check with monkeypatch.context() as mp: - mp.setattr("os.path.isdir", lambda *a: False) + mp.setattr("os.mkdir", causeOSError) + shutil.rmtree(os.path.join(fncDir, "meta")) assert theProject.saveProject() is False - # Fail on open file + # Fail writing with monkeypatch.context() as mp: - mp.setattr("builtins.open", causeOSError) + mp.setattr(ProjectXMLWriter, "write", lambda *a: False) assert theProject.saveProject() is False - # Fail on creating .bak file - with monkeypatch.context() as mp: - mp.setattr("os.replace", causeOSError) - assert theProject.saveProject() is False - assert os.path.isfile(backFile) is False - - # Successful save - saveCount = theProject.saveCount - autoCount = theProject.autoCount - assert theProject.saveProject() is True - assert theProject.saveCount == saveCount + 1 - assert theProject.autoCount == autoCount - assert cmpFiles(testFile, compFile, ignoreStart=XML_IGNORE) - - # Check that a second save creates a .bak file - assert os.path.isfile(backFile) is True - - # Successful autosave - saveCount = theProject.saveCount - autoCount = theProject.autoCount + # Save with and without autosave + assert theProject.saveProject(autoSave=False) is True assert theProject.saveProject(autoSave=True) is True - assert theProject.saveCount == saveCount - assert theProject.autoCount == autoCount + 1 - assert cmpFiles(testFile, compFile, ignoreStart=XML_IGNORE) - - # Close test project assert theProject.closeProject() # END Test testCoreProject_Save @@ -682,11 +609,11 @@ def testCoreProject_Helpers(monkeypatch, fncDir, mockGUI): @pytest.mark.core -def testCoreProject_AccessItems(nwMinimal, mockGUI): +def testCoreProject_AccessItems(mockGUI, fncDir, mockRnd): """Test helper functions for the project folder. """ theProject = NWProject(mockGUI) - theProject.openProject(nwMinimal) + buildTestProject(theProject, fncDir) # Storage Objects assert isinstance(theProject.index, NWIndex) @@ -695,34 +622,34 @@ def testCoreProject_AccessItems(nwMinimal, mockGUI): # Move Novel ROOT to after its files oldOrder = [ - "a508bb932959c", # ROOT: Novel - "a35baf2e93843", # FILE: Title Page - "a6d311a93600a", # FOLDER: New Chapter - "f5ab3e30151e1", # FILE: New Chapter - "8c659a11cd429", # FILE: New Scene - "7695ce551d265", # ROOT: Plot - "afb3043c7b2b3", # ROOT: Characters - "9d5247ab588e0", # ROOT: World + C.hNovelRoot, + C.hPlotRoot, + C.hCharRoot, + C.hWorldRoot, + C.hTitlePage, + C.hChapterDir, + C.hChapterDoc, + C.hSceneDoc, ] newOrder = [ - "a35baf2e93843", # FILE: Title Page - "f5ab3e30151e1", # FILE: New Chapter - "8c659a11cd429", # FILE: New Scene - "a6d311a93600a", # FOLDER: New Chapter - "a508bb932959c", # ROOT: Novel - "7695ce551d265", # ROOT: Plot - "afb3043c7b2b3", # ROOT: Characters - "9d5247ab588e0", # ROOT: World + C.hTitlePage, + C.hChapterDoc, + C.hSceneDoc, + C.hChapterDir, + C.hNovelRoot, + C.hPlotRoot, + C.hCharRoot, + C.hWorldRoot, ] assert theProject.tree.handles() == oldOrder assert theProject.setTreeOrder(newOrder) assert theProject.tree.handles() == newOrder # Add a non-existing item - theProject.tree._treeOrder.append("01234567789abc") + theProject.tree._treeOrder.append(C.hInvalid) # Add an item with a non-existent parent - nHandle = theProject.newFile("Test File", "a6d311a93600a") + nHandle = theProject.newFile("Test File", C.hChapterDir) theProject.tree[nHandle].setParent("cba9876543210") assert theProject.tree[nHandle].itemParent == "cba9876543210" @@ -731,15 +658,15 @@ def testCoreProject_AccessItems(nwMinimal, mockGUI): retOrder.append(tItem.itemHandle) assert retOrder == [ - "a508bb932959c", # ROOT: Novel - "7695ce551d265", # ROOT: Plot - "afb3043c7b2b3", # ROOT: Characters - "9d5247ab588e0", # ROOT: World - nHandle, # FILE: Test File - "a35baf2e93843", # FILE: Title Page - "a6d311a93600a", # FOLDER: New Chapter - "f5ab3e30151e1", # FILE: New Chapter - "8c659a11cd429", # FILE: New Scene + C.hNovelRoot, + C.hPlotRoot, + C.hCharRoot, + C.hWorldRoot, + nHandle, + C.hTitlePage, + C.hChapterDir, + C.hChapterDoc, + C.hSceneDoc, ] assert theProject.tree[nHandle].itemParent is None @@ -751,23 +678,24 @@ def testCoreProject_StatusImport(mockGUI, fncDir, mockRnd): """Test the status and importance flag handling. """ theProject = NWProject(mockGUI) + mockRnd.reset() buildTestProject(theProject, fncDir) - statusKeys = ["s000008", "s000009", "s00000a", "s00000b"] - importKeys = ["i00000c", "i00000d", "i00000e", "i00000f"] + statusKeys = [C.sNew, C.sNote, C.sDraft, C.sFinished] + importKeys = [C.iNew, C.iMinor, C.iMajor, C.iMain] # Change Status # ============= - theProject.tree["0000000000014"].setStatus("Finished") - theProject.tree["0000000000015"].setStatus("Draft") - theProject.tree["0000000000016"].setStatus("Note") - theProject.tree["0000000000017"].setStatus("Finished") + theProject.tree[C.hNovelRoot].setStatus(statusKeys[3]) + theProject.tree[C.hPlotRoot].setStatus(statusKeys[2]) + theProject.tree[C.hCharRoot].setStatus(statusKeys[1]) + theProject.tree[C.hWorldRoot].setStatus(statusKeys[3]) - assert theProject.tree["0000000000014"].itemStatus == statusKeys[3] - assert theProject.tree["0000000000015"].itemStatus == statusKeys[2] - assert theProject.tree["0000000000016"].itemStatus == statusKeys[1] - assert theProject.tree["0000000000017"].itemStatus == statusKeys[3] + assert theProject.tree[C.hNovelRoot].itemStatus == statusKeys[3] + assert theProject.tree[C.hPlotRoot].itemStatus == statusKeys[2] + assert theProject.tree[C.hCharRoot].itemStatus == statusKeys[1] + assert theProject.tree[C.hWorldRoot].itemStatus == statusKeys[3] newList = [ {"key": statusKeys[0], "name": "New", "cols": (1, 1, 1)}, @@ -780,30 +708,30 @@ def testCoreProject_StatusImport(mockGUI, fncDir, mockRnd): assert theProject.setStatusColours([], []) is False assert theProject.setStatusColours(newList, []) is True - assert theProject.statusItems.name(statusKeys[0]) == "New" - assert theProject.statusItems.name(statusKeys[1]) == "Draft" - assert theProject.statusItems.name(statusKeys[2]) == "Note" - assert theProject.statusItems.name(statusKeys[3]) == "Edited" - assert theProject.statusItems.cols(statusKeys[0]) == (1, 1, 1) - assert theProject.statusItems.cols(statusKeys[1]) == (2, 2, 2) - assert theProject.statusItems.cols(statusKeys[2]) == (3, 3, 3) - assert theProject.statusItems.cols(statusKeys[3]) == (4, 4, 4) + assert theProject.data.itemStatus.name(statusKeys[0]) == "New" + assert theProject.data.itemStatus.name(statusKeys[1]) == "Draft" + assert theProject.data.itemStatus.name(statusKeys[2]) == "Note" + assert theProject.data.itemStatus.name(statusKeys[3]) == "Edited" + assert theProject.data.itemStatus.cols(statusKeys[0]) == (1, 1, 1) + assert theProject.data.itemStatus.cols(statusKeys[1]) == (2, 2, 2) + assert theProject.data.itemStatus.cols(statusKeys[2]) == (3, 3, 3) + assert theProject.data.itemStatus.cols(statusKeys[3]) == (4, 4, 4) # Check the new entry - lastKey = theProject.statusItems.check("Finished") - assert lastKey == "s000018" - assert theProject.statusItems.name(lastKey) == "Finished" - assert theProject.statusItems.cols(lastKey) == (5, 5, 5) + lastKey = theProject.data.itemStatus.check("s000010") + assert lastKey == "s000010" + assert theProject.data.itemStatus.name(lastKey) == "Finished" + assert theProject.data.itemStatus.cols(lastKey) == (5, 5, 5) # Delete last entry assert theProject.setStatusColours([], [lastKey]) is True - assert theProject.statusItems.name(lastKey) == "New" + assert theProject.data.itemStatus.name(lastKey) == "New" # Change Importance # ================= - fHandle = theProject.newFile("Jane Doe", "0000000000012") - theProject.tree[fHandle].setImport("Main") + fHandle = theProject.newFile("Jane Doe", C.hCharRoot) + theProject.tree[fHandle].setImport(importKeys[3]) assert theProject.tree[fHandle].itemImport == importKeys[3] newList = [ @@ -817,53 +745,41 @@ def testCoreProject_StatusImport(mockGUI, fncDir, mockRnd): assert theProject.setImportColours([], []) is False assert theProject.setImportColours(newList, []) is True - assert theProject.importItems.name(importKeys[0]) == "New" - assert theProject.importItems.name(importKeys[1]) == "Minor" - assert theProject.importItems.name(importKeys[2]) == "Major" - assert theProject.importItems.name(importKeys[3]) == "Min" - assert theProject.importItems.cols(importKeys[0]) == (1, 1, 1) - assert theProject.importItems.cols(importKeys[1]) == (2, 2, 2) - assert theProject.importItems.cols(importKeys[2]) == (3, 3, 3) - assert theProject.importItems.cols(importKeys[3]) == (4, 4, 4) + assert theProject.data.itemImport.name(importKeys[0]) == "New" + assert theProject.data.itemImport.name(importKeys[1]) == "Minor" + assert theProject.data.itemImport.name(importKeys[2]) == "Major" + assert theProject.data.itemImport.name(importKeys[3]) == "Min" + assert theProject.data.itemImport.cols(importKeys[0]) == (1, 1, 1) + assert theProject.data.itemImport.cols(importKeys[1]) == (2, 2, 2) + assert theProject.data.itemImport.cols(importKeys[2]) == (3, 3, 3) + assert theProject.data.itemImport.cols(importKeys[3]) == (4, 4, 4) # Check the new entry - lastKey = theProject.importItems.check("Max") - assert lastKey == "i00001a" - assert theProject.importItems.name(lastKey) == "Max" - assert theProject.importItems.cols(lastKey) == (5, 5, 5) + lastKey = theProject.data.itemImport.check("i000012") + assert lastKey == "i000012" + assert theProject.data.itemImport.name(lastKey) == "Max" + assert theProject.data.itemImport.cols(lastKey) == (5, 5, 5) # Delete last entry assert theProject.setImportColours([], [lastKey]) is True - assert theProject.importItems.name(lastKey) == "New" + assert theProject.data.itemImport.name(lastKey) == "New" # Delete Status/Import # ==================== - theProject.statusItems.resetCounts() - for key in list(theProject.statusItems.keys()): - assert theProject.statusItems.remove(key) is True + theProject.data.itemStatus.resetCounts() + for key in list(theProject.data.itemStatus.keys()): + assert theProject.data.itemStatus.remove(key) is True - theProject.importItems.resetCounts() - for key in list(theProject.importItems.keys()): - assert theProject.importItems.remove(key) is True + theProject.data.itemImport.resetCounts() + for key in list(theProject.data.itemImport.keys()): + assert theProject.data.itemImport.remove(key) is True - assert len(theProject.statusItems) == 0 - assert len(theProject.importItems) == 0 + assert len(theProject.data.itemStatus) == 0 + assert len(theProject.data.itemImport) == 0 assert theProject.saveProject() is True assert theProject.closeProject() is True - # This should restore the default status/import labels - assert theProject.openProject(fncDir) is True - assert theProject.saveProject() is True - assert theProject.statusItems.name("s000023") == "New" - assert theProject.statusItems.name("s000024") == "Note" - assert theProject.statusItems.name("s000025") == "Draft" - assert theProject.statusItems.name("s000026") == "Finished" - assert theProject.importItems.name("i000027") == "New" - assert theProject.importItems.name("i000028") == "Minor" - assert theProject.importItems.name("i000029") == "Major" - assert theProject.importItems.name("i00002a") == "Main" - # END Test testCoreProject_StatusImport @@ -895,117 +811,104 @@ def testCoreProject_Methods(monkeypatch, mockGUI, tmpDir, fncDir, mockRnd): assert theProject.setProjectPath(fncDir) # Project Name - assert theProject.setProjectName(" A Name ") - assert theProject.projName == "A Name" + theProject.data.setName(" A Name ") + assert theProject.data.name == "A Name" # Project Title - assert theProject.setBookTitle(" A Title ") - assert theProject.bookTitle == "A Title" + theProject.data.setTitle(" A Title ") + assert theProject.data.title == "A Title" # Project Authors # Check that the list is cleaned up and that it can be extracted as # a properly formatted string, depending on number of names - assert not theProject.setBookAuthors([]) - assert theProject.setBookAuthors(" Jane Doe \n John Doh \n ") - assert theProject.bookAuthors == ["Jane Doe", "John Doh"] + theProject.data.setAuthors([]) + assert theProject.data.authors == [] + theProject.data.setAuthors(" Jane Doe \n John Doh \n ") + assert theProject.data.authors == ["Jane Doe", "John Doh"] - assert theProject.setBookAuthors("") - assert theProject.getAuthors() == "" + theProject.data.setAuthors("") + assert theProject.getFormattedAuthors() == "" - assert theProject.setBookAuthors("Jane Doe") - assert theProject.getAuthors() == "Jane Doe" + theProject.data.setAuthors("Jane Doe") + assert theProject.getFormattedAuthors() == "Jane Doe" - assert theProject.setBookAuthors("Jane Doe\nJohn Doh") - assert theProject.getAuthors() == "Jane Doe and John Doh" + theProject.data.setAuthors("Jane Doe\nJohn Doh") + assert theProject.getFormattedAuthors() == "Jane Doe and John Doh" - assert theProject.setBookAuthors("Jane Doe\nJohn Doh\nBod Owens") - assert theProject.getAuthors() == "Jane Doe, John Doh and Bod Owens" + theProject.data.setAuthors("Jane Doe\nJohn Doh\nBod Owens") + assert theProject.getFormattedAuthors() == "Jane Doe, John Doh and Bod Owens" # Edit Time - theProject.editTime = 1234 - theProject.projOpened = 1600000000 + theProject.data.setEditTime(1234) + theProject._projOpened = 1600000000 with monkeypatch.context() as mp: mp.setattr("novelwriter.core.project.time", lambda: 1600005600) assert theProject.getCurrentEditTime() == 6834 # Trash folder # Should create on first call, and just returned on later calls - hTrash = "0000000000018" + hTrash = "0000000000010" assert theProject.tree[hTrash] is None assert theProject.trashFolder() == hTrash assert theProject.trashFolder() == hTrash - # Project backup - assert theProject.doBackup is True - assert theProject.setProjBackup(False) - assert theProject.doBackup is False - - assert not theProject.setProjBackup(True) - theProject.mainConf.backupPath = tmpDir - assert theProject.setProjBackup(True) - - assert theProject.setProjectName("") - assert not theProject.setProjBackup(True) - assert theProject.setProjectName("A Name") - assert theProject.setProjBackup(True) - # Spell check - theProject.projChanged = False - assert theProject.setSpellCheck(True) - assert not theProject.setSpellCheck(False) + theProject.setProjectChanged(False) + theProject.data.setSpellCheck(True) + theProject.data.setSpellCheck(False) assert theProject.projChanged # Spell language - theProject.projChanged = False - assert theProject.projSpell is None - assert theProject.setSpellLang(None) is False - assert theProject.projSpell is None - assert theProject.setSpellLang("None") is False # Should be interpreded as None - assert theProject.projSpell is None - assert theProject.setSpellLang("en_GB") - assert theProject.projSpell == "en_GB" + theProject.setProjectChanged(False) + assert theProject.data.spellLang is None + theProject.data.setSpellLang(None) + assert theProject.data.spellLang is None + theProject.data.setSpellLang("None") # Should be interpreded as None + assert theProject.data.spellLang is None + theProject.data.setSpellLang("en_GB") + assert theProject.data.spellLang == "en_GB" assert theProject.projChanged # Project Language - theProject.projChanged = False - theProject.projLang = "en" + theProject.setProjectChanged(False) + theProject.data.setLanguage("en") assert theProject.setProjectLang(None) is True - assert theProject.projLang is None + assert theProject.data.language is None assert theProject.setProjectLang("en_GB") is True - assert theProject.projLang == "en_GB" + assert theProject.data.language == "en_GB" # Language Lookup assert theProject.localLookup(1) == "One" assert theProject.localLookup(10) == "Ten" # Last edited - theProject.projChanged = False - assert theProject.setLastEdited("0123456789abc") - assert theProject.lastEdited == "0123456789abc" + theProject.setProjectChanged(False) + theProject._data.setLastHandle("0123456789abc", "editor") + assert theProject._data.getLastHandle("editor") == "0123456789abc" assert theProject.projChanged # Last viewed - theProject.projChanged = False - assert theProject.setLastViewed("0123456789abc") - assert theProject.lastViewed == "0123456789abc" + theProject.setProjectChanged(False) + theProject._data.setLastHandle("0123456789abc", "viewer") + assert theProject._data.getLastHandle("viewer") == "0123456789abc" assert theProject.projChanged # Autoreplace - theProject.projChanged = False - assert theProject.setAutoReplace({"A": "B", "C": "D"}) - assert theProject.autoReplace == {"A": "B", "C": "D"} + theProject.setProjectChanged(False) + theProject.data.setAutoReplace({"A": "B", "C": "D"}) + assert theProject.data.autoReplace == {"A": "B", "C": "D"} assert theProject.projChanged # Change project tree order oldOrder = [ - "0000000000010", "0000000000011", "0000000000012", - "0000000000013", "0000000000014", "0000000000015", - "0000000000016", "0000000000017", "0000000000018", + "0000000000008", "0000000000009", "000000000000a", + "000000000000b", "000000000000c", "000000000000d", + "000000000000e", "000000000000f", "0000000000010", ] newOrder = [ - "0000000000013", "0000000000014", "0000000000015", - "0000000000010", "0000000000011", "0000000000012", - "0000000000016", "0000000000017", + "000000000000b", "000000000000c", "000000000000d", + "0000000000008", "0000000000009", "000000000000a", + "000000000000e", "000000000000f", ] assert theProject.tree.handles() == oldOrder assert theProject.setTreeOrder(newOrder) @@ -1014,8 +917,8 @@ def testCoreProject_Methods(monkeypatch, mockGUI, tmpDir, fncDir, mockRnd): assert theProject.tree.handles() == oldOrder # Session stats - theProject.currWCount = 200 - theProject.lastWCount = 100 + theProject._data._initCounts = [50, 50] + theProject._data._currCounts = [100, 100] with monkeypatch.context() as mp: mp.setattr("os.path.isdir", lambda *a, **k: False) assert not theProject._appendSessionStats(idleTime=0) @@ -1029,9 +932,8 @@ def testCoreProject_Methods(monkeypatch, mockGUI, tmpDir, fncDir, mockRnd): assert theProject.projMeta == os.path.join(fncDir, "meta") statsFile = os.path.join(theProject.projMeta, nwFiles.SESS_STATS) - theProject.projOpened = 1600002000 - theProject.currNovelWC = 200 - theProject.currNotesWC = 100 + theProject._projOpened = 1600002000 + theProject._data._currCounts = [200, 100] with monkeypatch.context() as mp: mp.setattr("novelwriter.core.project.time", lambda: 1600005600) @@ -1043,31 +945,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 @@ -1305,14 +1182,14 @@ def testCoreProject_LegacyData(monkeypatch, mockGUI, fncDir): @pytest.mark.core -def testCoreProject_Backup(monkeypatch, mockGUI, nwMinimal, tmpDir): +def testCoreProject_Backup(monkeypatch, mockGUI, fncDir, tmpDir): """Test the automated backup feature of the project class. The test creates a backup of the Minimal test project, and then unzips the backupd file and checks that the project XML file is identical to the original file. """ theProject = NWProject(mockGUI) - assert theProject.openProject(nwMinimal) + buildTestProject(theProject, fncDir) # Test faulty settings @@ -1327,20 +1204,20 @@ def testCoreProject_Backup(monkeypatch, mockGUI, nwMinimal, tmpDir): # Missing project name theProject.mainConf.backupPath = tmpDir - theProject.projName = "" + theProject.data.setName("") assert theProject.zipIt(doNotify=False) is False # Non-existent folder theProject.mainConf.backupPath = os.path.join(tmpDir, "nonexistent") - theProject.projName = "Test Minimal" + theProject.data.setName("Test Minimal") assert theProject.zipIt(doNotify=False) is False # Same folder as project (causes infinite loop in zipping) - theProject.mainConf.backupPath = nwMinimal + theProject.mainConf.backupPath = fncDir assert theProject.zipIt(doNotify=False) is False # Subfolder of project (causes infinite loop in zipping) - theProject.mainConf.backupPath = os.path.join(nwMinimal, "subdir") + theProject.mainConf.backupPath = os.path.join(fncDir, "subdir") assert theProject.zipIt(doNotify=False) is False # Set a valid folder @@ -1372,7 +1249,7 @@ def testCoreProject_Backup(monkeypatch, mockGUI, nwMinimal, tmpDir): # Check that the main project file was restored assert cmpFiles( - os.path.join(nwMinimal, "nwProject.nwx"), + os.path.join(fncDir, "nwProject.nwx"), os.path.join(tmpDir, "extract", "nwProject.nwx") ) diff --git a/tests/test_core/test_core_projectxml.py b/tests/test_core/test_core_projectxml.py new file mode 100644 index 00000000..6ad1174c --- /dev/null +++ b/tests/test_core/test_core_projectxml.py @@ -0,0 +1,798 @@ +""" +novelWriter – ProjectXMLReader/Writer Class Tester +================================================== + +This file is a part of novelWriter +Copyright 2018–2022, Veronica Berglyd Olsen + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, but +WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +""" + +import json +import os +import pytest +import shutil + +from datetime import datetime + +from mock import causeOSError +from tools import cmpFiles, writeFile + +from novelwriter.core.item import NWItem +from novelwriter.core.project import NWProjectData +from novelwriter.core.projectxml import ProjectXMLReader, ProjectXMLWriter, XMLReadState + + +class MockProject: + def setProjectChanged(self, *a): + pass + + +@pytest.mark.core +def testCoreProjectXML_ReadCurrent(monkeypatch, filesDir, fncDir, outDir, refDir): + """Test reading the current XML file format. + """ + refFile = os.path.join(filesDir, "nwProject-1.4.nwx") + xmlFile = os.path.join(fncDir, "nwProject-1.4.nwx") + bakFile = os.path.join(fncDir, "nwProject-1.4.bak") + outFile = os.path.join(fncDir, "nwProject.nwx") + + xmlReader = ProjectXMLReader(xmlFile) + assert xmlReader.state == XMLReadState.NO_ACTION + + data = NWProjectData(MockProject()) + content = [] + + # With no valid files, the read should fail + writeFile(xmlFile, "") + assert xmlReader.read(data, content) is False + assert xmlReader.state == XMLReadState.CANNOT_PARSE + + # Also add an invalid backup file + writeFile(bakFile, "") + assert xmlReader.read(data, content) is False + assert xmlReader.state == XMLReadState.CANNOT_PARSE + + # Add a valid backup file, that is not novelWriter + writeFile(bakFile, "") + assert xmlReader.read(data, content) is False + assert xmlReader.state == XMLReadState.NOT_NWX_FILE + + # Add a valid project file, that is not novelWriter + writeFile(xmlFile, "") + assert xmlReader.read(data, content) is False + assert xmlReader.state == XMLReadState.NOT_NWX_FILE + + # Add a valid novelwriter file without a file version + writeFile(xmlFile, "") + assert xmlReader.read(data, content) is False + assert xmlReader.state == XMLReadState.UNKNOWN_VERSION + + # Check parsing of unkown sections + writeFile(xmlFile, ( + "" + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + "" + )) + assert xmlReader.read(data, content) is True + assert xmlReader.state == XMLReadState.PARSED_OK + + writeFile(xmlFile, ( + "" + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + " " + "" + )) + assert xmlReader.read(data, content) is True + assert xmlReader.state == XMLReadState.WAS_LEGACY + + # Reset data objects + data = NWProjectData(MockProject()) + content = [] + + # Parse a valid, complete file + shutil.copy(refFile, xmlFile) + assert xmlReader.read(data, content) is True + assert xmlReader.state == XMLReadState.PARSED_OK + assert xmlReader.xmlRoot == "novelWriterXML" + assert xmlReader.xmlVersion == 0x0104 + assert xmlReader.appVersion == "2.0-rc1" + assert xmlReader.hexVersion == "0x020000c1" + + # Check loaded data + assert data.name == "Sample Project" + assert data.title == "Sample Project" + assert data.authors == ["Jane Smith", "Jay Doh"] + assert data.saveCount == 5 + assert data.autoCount == 10 + assert data.editTime == 1000 + + assert data.doBackup is True + assert data.language == "en_GB" + assert data.spellCheck is True + assert data.spellLang == "en_GB" + assert data.initCounts == (954, 409) + assert data.currCounts == (954, 409) + + assert data.getLastHandle("editor") == "636b6aa9b697b" + assert data.getLastHandle("viewer") == "636b6aa9b697b" + assert data.getLastHandle("novelTree") == "7031beac91f75" + assert data.getLastHandle("outline") == "7031beac91f75" + + assert data.getTitleFormat("title") == "%title%" + assert data.getTitleFormat("chapter") == "Chapter %chw%: %title%" + assert data.getTitleFormat("unnumbered") == "%title%" + assert data.getTitleFormat("scene") == "Scene %ch%.%sc%: %title%" + assert data.getTitleFormat("section") == "" + + assert data.itemStatus.name("sf12341") == "New" + assert data.itemStatus.name("sf24ce6") == "Notes" + assert data.itemStatus.name("sc24b8f") == "Started" + assert data.itemStatus.name("s90e6c9") == "1st Draft" + assert data.itemStatus.name("sd51c5b") == "2nd Draft" + assert data.itemStatus.name("s8ae72a") == "3rd Draft" + assert data.itemStatus.name("s78ea90") == "Finished" + + assert data.itemImport.name("ia857f0") == "None" + assert data.itemImport.name("icfb3a5") == "Minor" + assert data.itemImport.name("i2d7a54") == "Major" + assert data.itemImport.name("i56be10") == "Main" + + assert data.itemStatus.cols("sf12341") == (100, 100, 100) + assert data.itemStatus.cols("sf24ce6") == (200, 50, 0) + assert data.itemStatus.cols("sc24b8f") == (182, 60, 0) + assert data.itemStatus.cols("s90e6c9") == (193, 129, 0) + assert data.itemStatus.cols("sd51c5b") == (193, 129, 0) + assert data.itemStatus.cols("s8ae72a") == (193, 129, 0) + assert data.itemStatus.cols("s78ea90") == (58, 180, 58) + + assert data.itemImport.cols("ia857f0") == (100, 100, 100) + assert data.itemImport.cols("icfb3a5") == (0, 122, 188) + assert data.itemImport.cols("i2d7a54") == (21, 0, 180) + assert data.itemImport.cols("i56be10") == (117, 0, 175) + + assert data.itemStatus.count("sf12341") == 4 + assert data.itemStatus.count("sf24ce6") == 2 + assert data.itemStatus.count("sc24b8f") == 3 + assert data.itemStatus.count("s90e6c9") == 7 + assert data.itemStatus.count("sd51c5b") == 0 + assert data.itemStatus.count("s8ae72a") == 0 + assert data.itemStatus.count("s78ea90") == 1 + + assert data.itemImport.count("ia857f0") == 5 + assert data.itemImport.count("icfb3a5") == 2 + assert data.itemImport.count("i2d7a54") == 2 + assert data.itemImport.count("i56be10") == 1 + + # Compare content + dumpFile = os.path.join(outDir, "projectXML_ReadCurrent.json") + compFile = os.path.join(refDir, "projectXML_ReadCurrent.json") + with open(dumpFile, mode="w", encoding="utf-8") as dump: + json.dump(content, dump, indent=2) + assert cmpFiles(dumpFile, compFile) + + packedContent = [] + mockProject = MockProject() + mockProject.__setattr__("data", data) + for entry in content: + item = NWItem(mockProject) + item.unpack(entry) + packedContent.append(item.pack()) + + # Save the project again, which should produce an identical project xml + timeStamp = int(datetime.fromisoformat(xmlReader.timeStamp).timestamp()) + xmlWriter = ProjectXMLWriter(fncDir) + + # Fail saving + with monkeypatch.context() as mp: + mp.setattr("builtins.open", causeOSError) + assert xmlWriter.write(data, packedContent, timeStamp, 1000) is False + assert str(xmlWriter.error) == "Mock OSError" + + with monkeypatch.context() as mp: + mp.setattr("os.replace", causeOSError) + assert xmlWriter.write(data, packedContent, timeStamp, 1000) is False + assert str(xmlWriter.error) == "Mock OSError" + + # Successful save (should be twice) + assert xmlWriter.write(data, packedContent, timeStamp, 1000) is True + assert xmlWriter.write(data, packedContent, timeStamp, 1000) is True + assert cmpFiles(outFile, xmlFile) + +# END Test testCoreProjectXML_ReadCurrent + + +@pytest.mark.core +def testCoreProjectXML_ReadLegacy10(filesDir, fncDir, outDir, refDir, mockRnd): + """Test reading the version 1.0 XML file format. + """ + refFile = os.path.join(filesDir, "nwProject-1.0.nwx") + xmlFile = os.path.join(fncDir, "nwProject-1.0.nwx") + outFile = os.path.join(fncDir, "nwProject.nwx") + shutil.copy(refFile, xmlFile) + + xmlReader = ProjectXMLReader(xmlFile) + assert xmlReader.state == XMLReadState.NO_ACTION + + data = NWProjectData(MockProject()) + content = [] + + assert xmlReader.read(data, content) is True + assert xmlReader.state == XMLReadState.WAS_LEGACY + assert xmlReader.xmlRoot == "novelWriterXML" + assert xmlReader.xmlVersion == 0x0100 + assert xmlReader.appVersion == "0.6.1" + assert xmlReader.hexVersion == "0x000601f0" + + # Check loaded data + assert data.name == "Sample Project" + assert data.title == "Sample Project" + assert data.authors == ["Jane Smith", "Jay Doh"] + assert data.saveCount == 0 # Doesn't exist in 1.0 + assert data.autoCount == 0 # Doesn't exist in 1.0 + assert data.editTime == 0 # Doesn't exist in 1.0 + + assert data.doBackup is True + assert data.language is None # Doesn't exist in 1.0 + assert data.spellCheck is True + assert data.spellLang is None # Doesn't exist in 1.0 + assert data.initCounts == (0, 0) + assert data.currCounts == (0, 0) + + assert data.getLastHandle("editor") is None # Dropped by conversion + assert data.getLastHandle("viewer") is None # Dropped by conversion + assert data.getLastHandle("novelTree") is None # Doesn't exist in 1.0 + assert data.getLastHandle("outline") is None # Doesn't exist in 1.0 + + assert data.getTitleFormat("title") == "%title%" + assert data.getTitleFormat("chapter") == "Chapter %ch%: %title%" + assert data.getTitleFormat("unnumbered") == "%title%" + assert data.getTitleFormat("scene") == "Scene %ch%.%sc%: %title%" + assert data.getTitleFormat("section") == "" + + assert data.itemStatus.name("s000000") == "New" + assert data.itemStatus.name("s000001") == "Notes" + assert data.itemStatus.name("s000002") == "Started" + assert data.itemStatus.name("s000003") == "1st Draft" + assert data.itemStatus.name("s000004") == "2nd Draft" + assert data.itemStatus.name("s000005") == "3rd Draft" + assert data.itemStatus.name("s000006") == "Finished" + + assert data.itemImport.name("i000007") == "None" + assert data.itemImport.name("i000008") == "Minor" + assert data.itemImport.name("i000009") == "Major" + assert data.itemImport.name("i00000a") == "Main" + + assert data.itemStatus.cols("s000000") == (100, 100, 100) + assert data.itemStatus.cols("s000001") == (200, 50, 0) + assert data.itemStatus.cols("s000002") == (182, 60, 0) + assert data.itemStatus.cols("s000003") == (193, 129, 0) + assert data.itemStatus.cols("s000004") == (193, 129, 0) + assert data.itemStatus.cols("s000005") == (193, 129, 0) + assert data.itemStatus.cols("s000006") == (58, 180, 58) + + assert data.itemImport.cols("i000007") == (100, 100, 100) + assert data.itemImport.cols("i000008") == (0, 122, 188) + assert data.itemImport.cols("i000009") == (21, 0, 180) + assert data.itemImport.cols("i00000a") == (117, 0, 175) + + assert data.itemStatus.count("s000000") == 0 + assert data.itemStatus.count("s000001") == 0 + assert data.itemStatus.count("s000002") == 0 + assert data.itemStatus.count("s000003") == 0 + assert data.itemStatus.count("s000004") == 0 + assert data.itemStatus.count("s000005") == 0 + assert data.itemStatus.count("s000006") == 0 + + assert data.itemImport.count("i000007") == 0 + assert data.itemImport.count("i000008") == 0 + assert data.itemImport.count("i000009") == 0 + assert data.itemImport.count("i00000a") == 0 + + # Compare content + dumpFile = os.path.join(outDir, "projectXML_ReadLegacy10.json") + compFile = os.path.join(refDir, "projectXML_ReadLegacy10.json") + with open(dumpFile, mode="w", encoding="utf-8") as dump: + json.dump(content, dump, indent=2) + assert cmpFiles(dumpFile, compFile) + + packedContent = [] + mockProject = MockProject() + mockProject.__setattr__("data", data) + status = {} + for entry in content: + item = NWItem(mockProject) + item.unpack(entry) + status[item.itemHandle] = item.getImportStatus(incIcon=False)[0] + packedContent.append(item.pack()) + + assert status == { + "7031beac91f75": "Started", + "53b69b83cdafc": "Started", + "974e400180a99": "New", + "edca4be2fcaf8": "New", + "e7ded148d6e4a": "1st Draft", + "6a2d6d5f4f401": "Notes", + "636b6aa9b697b": "1st Draft", + "bc0cbd2a407f3": "1st Draft", + "ba8a28a246524": "Finished", + "96b68994dfa3d": "2nd Draft", + "88706ddc78b1b": "1st Draft", + "ae7339df26ded": "1st Draft", + "f6622b4617424": "None", + "f7e2d9f330615": "None", + "14298de4d9524": "Minor", + "bb2c23b3c42cc": "Major", + "15c4492bd5107": "None", + "b3e74dbc1f584": "Main", + "f1471bef9f2ae": "Minor", + "5eaea4e8cdee8": "Major", + "98acd8c76c93a": "None", + "b8136a5a774a0": "New", + } + + # Save the project again, which should produce an identical project xml + timeStamp = int(datetime.fromisoformat(xmlReader.timeStamp).timestamp()) + xmlWriter = ProjectXMLWriter(fncDir) + assert xmlWriter.write(data, packedContent, timeStamp, 1000) is True + compFile = os.path.join(refDir, "projectXML_ReadLegacy10.nwx") + assert cmpFiles(outFile, compFile) + +# END Test testCoreProjectXML_ReadLegacy10 + + +@pytest.mark.core +def testCoreProjectXML_ReadLegacy11(filesDir, fncDir, outDir, refDir, mockRnd): + """Test reading the version 1.1 XML file format. + """ + refFile = os.path.join(filesDir, "nwProject-1.1.nwx") + xmlFile = os.path.join(fncDir, "nwProject-1.1.nwx") + outFile = os.path.join(fncDir, "nwProject.nwx") + shutil.copy(refFile, xmlFile) + + xmlReader = ProjectXMLReader(xmlFile) + assert xmlReader.state == XMLReadState.NO_ACTION + + data = NWProjectData(MockProject()) + content = [] + + assert xmlReader.read(data, content) is True + assert xmlReader.state == XMLReadState.WAS_LEGACY + assert xmlReader.xmlRoot == "novelWriterXML" + assert xmlReader.xmlVersion == 0x0101 + assert xmlReader.appVersion == "0.9.2" + assert xmlReader.hexVersion == "0x000902f0" + + # Check loaded data + assert data.name == "Sample Project" + assert data.title == "Sample Project" + assert data.authors == ["Jane Smith", "Jay Doh"] + assert data.saveCount == 5 + assert data.autoCount == 10 + assert data.editTime == 1000 + + assert data.doBackup is True + assert data.language is None # Doesn't exist in 1.1 + assert data.spellCheck is True + assert data.spellLang is None # Doesn't exist in 1.1 + assert data.initCounts == (0, 0) + assert data.currCounts == (0, 0) + + assert data.getLastHandle("editor") is None # Dropped by conversion + assert data.getLastHandle("viewer") is None # Dropped by conversion + assert data.getLastHandle("novelTree") is None # Doesn't exist in 1.1 + assert data.getLastHandle("outline") is None # Doesn't exist in 1.1 + + assert data.getTitleFormat("title") == "%title%" + assert data.getTitleFormat("chapter") == "Chapter %ch%: %title%" + assert data.getTitleFormat("unnumbered") == "%title%" + assert data.getTitleFormat("scene") == "Scene %ch%.%sc%: %title%" + assert data.getTitleFormat("section") == "" + + assert data.itemStatus.name("s000000") == "New" + assert data.itemStatus.name("s000001") == "Notes" + assert data.itemStatus.name("s000002") == "Started" + assert data.itemStatus.name("s000003") == "1st Draft" + assert data.itemStatus.name("s000004") == "2nd Draft" + assert data.itemStatus.name("s000005") == "3rd Draft" + assert data.itemStatus.name("s000006") == "Finished" + + assert data.itemImport.name("i000007") == "None" + assert data.itemImport.name("i000008") == "Minor" + assert data.itemImport.name("i000009") == "Major" + assert data.itemImport.name("i00000a") == "Main" + + assert data.itemStatus.cols("s000000") == (100, 100, 100) + assert data.itemStatus.cols("s000001") == (200, 50, 0) + assert data.itemStatus.cols("s000002") == (182, 60, 0) + assert data.itemStatus.cols("s000003") == (193, 129, 0) + assert data.itemStatus.cols("s000004") == (193, 129, 0) + assert data.itemStatus.cols("s000005") == (193, 129, 0) + assert data.itemStatus.cols("s000006") == (58, 180, 58) + + assert data.itemImport.cols("i000007") == (100, 100, 100) + assert data.itemImport.cols("i000008") == (0, 122, 188) + assert data.itemImport.cols("i000009") == (21, 0, 180) + assert data.itemImport.cols("i00000a") == (117, 0, 175) + + assert data.itemStatus.count("s000000") == 0 + assert data.itemStatus.count("s000001") == 0 + assert data.itemStatus.count("s000002") == 0 + assert data.itemStatus.count("s000003") == 0 + assert data.itemStatus.count("s000004") == 0 + assert data.itemStatus.count("s000005") == 0 + assert data.itemStatus.count("s000006") == 0 + + assert data.itemImport.count("i000007") == 0 + assert data.itemImport.count("i000008") == 0 + assert data.itemImport.count("i000009") == 0 + assert data.itemImport.count("i00000a") == 0 + + # Compare content + dumpFile = os.path.join(outDir, "projectXML_ReadLegacy11.json") + compFile = os.path.join(refDir, "projectXML_ReadLegacy11.json") + with open(dumpFile, mode="w", encoding="utf-8") as dump: + json.dump(content, dump, indent=2) + assert cmpFiles(dumpFile, compFile) + + packedContent = [] + mockProject = MockProject() + mockProject.__setattr__("data", data) + status = {} + for entry in content: + item = NWItem(mockProject) + item.unpack(entry) + status[item.itemHandle] = item.getImportStatus(incIcon=False)[0] + packedContent.append(item.pack()) + + assert status == { + "7031beac91f75": "Started", + "53b69b83cdafc": "Started", + "974e400180a99": "New", + "edca4be2fcaf8": "New", + "e7ded148d6e4a": "1st Draft", + "6a2d6d5f4f401": "Notes", + "636b6aa9b697b": "1st Draft", + "bc0cbd2a407f3": "1st Draft", + "ba8a28a246524": "Finished", + "96b68994dfa3d": "2nd Draft", + "88706ddc78b1b": "1st Draft", + "ae7339df26ded": "1st Draft", + "f6622b4617424": "None", + "f7e2d9f330615": "None", + "14298de4d9524": "Minor", + "bb2c23b3c42cc": "Major", + "15c4492bd5107": "None", + "b3e74dbc1f584": "Main", + "f1471bef9f2ae": "Minor", + "5eaea4e8cdee8": "Major", + "98acd8c76c93a": "None", + "b8136a5a774a0": "New", + } + + # Save the project again, which should produce an identical project xml + timeStamp = int(datetime.fromisoformat(xmlReader.timeStamp).timestamp()) + xmlWriter = ProjectXMLWriter(fncDir) + assert xmlWriter.write(data, packedContent, timeStamp, 1000) is True + compFile = os.path.join(refDir, "projectXML_ReadLegacy11.nwx") + assert cmpFiles(outFile, compFile) + +# END Test testCoreProjectXML_ReadLegacy11 + + +@pytest.mark.core +def testCoreProjectXML_ReadLegacy12(filesDir, fncDir, outDir, refDir, mockRnd): + """Test reading the version 1.2 XML file format. + """ + refFile = os.path.join(filesDir, "nwProject-1.2.nwx") + xmlFile = os.path.join(fncDir, "nwProject-1.2.nwx") + outFile = os.path.join(fncDir, "nwProject.nwx") + shutil.copy(refFile, xmlFile) + + xmlReader = ProjectXMLReader(xmlFile) + assert xmlReader.state == XMLReadState.NO_ACTION + + data = NWProjectData(MockProject()) + content = [] + + assert xmlReader.read(data, content) is True + assert xmlReader.state == XMLReadState.WAS_LEGACY + assert xmlReader.xmlRoot == "novelWriterXML" + assert xmlReader.xmlVersion == 0x0102 + assert xmlReader.appVersion == "1.4.2" + assert xmlReader.hexVersion == "0x010402f0" + + # Check loaded data + assert data.name == "Sample Project" + assert data.title == "Sample Project" + assert data.authors == ["Jane Smith", "Jay Doh"] + assert data.saveCount == 5 + assert data.autoCount == 10 + assert data.editTime == 1000 + + assert data.doBackup is True + assert data.language == "en_GB" + assert data.spellCheck is True + assert data.spellLang == "en_GB" + assert data.initCounts == (840, 376) + assert data.currCounts == (840, 376) + + assert data.getLastHandle("editor") is None # Dropped by conversion + assert data.getLastHandle("viewer") is None # Dropped by conversion + assert data.getLastHandle("novelTree") is None # Doesn't exist in 1.2 + assert data.getLastHandle("outline") is None # Doesn't exist in 1.2 + + assert data.getTitleFormat("title") == "%title%" + assert data.getTitleFormat("chapter") == "Chapter %chw%: %title%" + assert data.getTitleFormat("unnumbered") == "%title%" + assert data.getTitleFormat("scene") == "Scene %ch%.%sc%: %title%" + assert data.getTitleFormat("section") == "" + + assert data.itemStatus.name("s000000") == "New" + assert data.itemStatus.name("s000001") == "Notes" + assert data.itemStatus.name("s000002") == "Started" + assert data.itemStatus.name("s000003") == "1st Draft" + assert data.itemStatus.name("s000004") == "2nd Draft" + assert data.itemStatus.name("s000005") == "3rd Draft" + assert data.itemStatus.name("s000006") == "Finished" + + assert data.itemImport.name("i000007") == "None" + assert data.itemImport.name("i000008") == "Minor" + assert data.itemImport.name("i000009") == "Major" + assert data.itemImport.name("i00000a") == "Main" + + assert data.itemStatus.cols("s000000") == (100, 100, 100) + assert data.itemStatus.cols("s000001") == (200, 50, 0) + assert data.itemStatus.cols("s000002") == (182, 60, 0) + assert data.itemStatus.cols("s000003") == (193, 129, 0) + assert data.itemStatus.cols("s000004") == (193, 129, 0) + assert data.itemStatus.cols("s000005") == (193, 129, 0) + assert data.itemStatus.cols("s000006") == (58, 180, 58) + + assert data.itemImport.cols("i000007") == (100, 100, 100) + assert data.itemImport.cols("i000008") == (0, 122, 188) + assert data.itemImport.cols("i000009") == (21, 0, 180) + assert data.itemImport.cols("i00000a") == (117, 0, 175) + + assert data.itemStatus.count("s000000") == 0 + assert data.itemStatus.count("s000001") == 0 + assert data.itemStatus.count("s000002") == 0 + assert data.itemStatus.count("s000003") == 0 + assert data.itemStatus.count("s000004") == 0 + assert data.itemStatus.count("s000005") == 0 + assert data.itemStatus.count("s000006") == 0 + + assert data.itemImport.count("i000007") == 0 + assert data.itemImport.count("i000008") == 0 + assert data.itemImport.count("i000009") == 0 + assert data.itemImport.count("i00000a") == 0 + + # Compare content + dumpFile = os.path.join(outDir, "projectXML_ReadLegacy12.json") + compFile = os.path.join(refDir, "projectXML_ReadLegacy12.json") + with open(dumpFile, mode="w", encoding="utf-8") as dump: + json.dump(content, dump, indent=2) + assert cmpFiles(dumpFile, compFile) + + packedContent = [] + mockProject = MockProject() + mockProject.__setattr__("data", data) + status = {} + for entry in content: + item = NWItem(mockProject) + item.unpack(entry) + status[item.itemHandle] = item.getImportStatus(incIcon=False)[0] + packedContent.append(item.pack()) + + assert status == { + "7031beac91f75": "Started", + "53b69b83cdafc": "Started", + "974e400180a99": "New", + "edca4be2fcaf8": "New", + "e7ded148d6e4a": "1st Draft", + "6a2d6d5f4f401": "Notes", + "636b6aa9b697b": "1st Draft", + "bc0cbd2a407f3": "1st Draft", + "ba8a28a246524": "New", + "96b68994dfa3d": "2nd Draft", + "88706ddc78b1b": "1st Draft", + "ae7339df26ded": "1st Draft", + "f6622b4617424": "None", + "f7e2d9f330615": "None", + "14298de4d9524": "Minor", + "bb2c23b3c42cc": "Major", + "15c4492bd5107": "None", + "b3e74dbc1f584": "Main", + "f1471bef9f2ae": "Minor", + "5eaea4e8cdee8": "Major", + "6827118336ac1": "New", # Is now treated as novel-like + "ae9bf3c3ea159": "New", # Is now treated as novel-like + "8a5deb88c0e97": "1st Draft", + "98acd8c76c93a": "None", + "b8136a5a774a0": "New", + } + + # Save the project again, which should produce an identical project xml + timeStamp = int(datetime.fromisoformat(xmlReader.timeStamp).timestamp()) + xmlWriter = ProjectXMLWriter(fncDir) + assert xmlWriter.write(data, packedContent, timeStamp, 1000) is True + compFile = os.path.join(refDir, "projectXML_ReadLegacy12.nwx") + assert cmpFiles(outFile, compFile) + +# END Test testCoreProjectXML_ReadLegacy12 + + +@pytest.mark.core +def testCoreProjectXML_ReadLegacy13(filesDir, fncDir, outDir, refDir, mockRnd): + """Test reading the version 1.3 XML file format. + """ + refFile = os.path.join(filesDir, "nwProject-1.3.nwx") + xmlFile = os.path.join(fncDir, "nwProject-1.3.nwx") + outFile = os.path.join(fncDir, "nwProject.nwx") + shutil.copy(refFile, xmlFile) + + xmlReader = ProjectXMLReader(xmlFile) + assert xmlReader.state == XMLReadState.NO_ACTION + + data = NWProjectData(MockProject()) + content = [] + + assert xmlReader.read(data, content) is True + assert xmlReader.state == XMLReadState.WAS_LEGACY + assert xmlReader.xmlRoot == "novelWriterXML" + assert xmlReader.xmlVersion == 0x0103 + assert xmlReader.appVersion == "1.6.6" + assert xmlReader.hexVersion == "0x010606f0" + + # Check loaded data + assert data.name == "Sample Project" + assert data.title == "Sample Project" + assert data.authors == ["Jane Smith", "Jay Doh"] + assert data.saveCount == 5 + assert data.autoCount == 10 + assert data.editTime == 1000 + + assert data.doBackup is True + assert data.language == "en_GB" + assert data.spellCheck is True + assert data.spellLang == "en_GB" + assert data.initCounts == (830, 376) + assert data.currCounts == (830, 376) + + assert data.getLastHandle("editor") is None # Dropped by conversion + assert data.getLastHandle("viewer") is None # Dropped by conversion + assert data.getLastHandle("novelTree") is None # Doesn't exist in 1.3 + assert data.getLastHandle("outline") is None # Doesn't exist in 1.3 + + assert data.getTitleFormat("title") == "%title%" + assert data.getTitleFormat("chapter") == "Chapter %chw%: %title%" + assert data.getTitleFormat("unnumbered") == "%title%" + assert data.getTitleFormat("scene") == "Scene %ch%.%sc%: %title%" + assert data.getTitleFormat("section") == "" + + assert data.itemStatus.name("s000000") == "New" + assert data.itemStatus.name("s000001") == "Notes" + assert data.itemStatus.name("s000002") == "Started" + assert data.itemStatus.name("s000003") == "1st Draft" + assert data.itemStatus.name("s000004") == "2nd Draft" + assert data.itemStatus.name("s000005") == "3rd Draft" + assert data.itemStatus.name("s000006") == "Finished" + + assert data.itemImport.name("i000007") == "None" + assert data.itemImport.name("i000008") == "Minor" + assert data.itemImport.name("i000009") == "Major" + assert data.itemImport.name("i00000a") == "Main" + + assert data.itemStatus.cols("s000000") == (100, 100, 100) + assert data.itemStatus.cols("s000001") == (200, 50, 0) + assert data.itemStatus.cols("s000002") == (182, 60, 0) + assert data.itemStatus.cols("s000003") == (193, 129, 0) + assert data.itemStatus.cols("s000004") == (193, 129, 0) + assert data.itemStatus.cols("s000005") == (193, 129, 0) + assert data.itemStatus.cols("s000006") == (58, 180, 58) + + assert data.itemImport.cols("i000007") == (100, 100, 100) + assert data.itemImport.cols("i000008") == (0, 122, 188) + assert data.itemImport.cols("i000009") == (21, 0, 180) + assert data.itemImport.cols("i00000a") == (117, 0, 175) + + assert data.itemStatus.count("s000000") == 0 + assert data.itemStatus.count("s000001") == 0 + assert data.itemStatus.count("s000002") == 0 + assert data.itemStatus.count("s000003") == 0 + assert data.itemStatus.count("s000004") == 0 + assert data.itemStatus.count("s000005") == 0 + assert data.itemStatus.count("s000006") == 0 + + assert data.itemImport.count("i000007") == 0 + assert data.itemImport.count("i000008") == 0 + assert data.itemImport.count("i000009") == 0 + assert data.itemImport.count("i00000a") == 0 + + # Compare content + dumpFile = os.path.join(outDir, "projectXML_ReadLegacy13.json") + compFile = os.path.join(refDir, "projectXML_ReadLegacy13.json") + with open(dumpFile, mode="w", encoding="utf-8") as dump: + json.dump(content, dump, indent=2) + assert cmpFiles(dumpFile, compFile) + + packedContent = [] + mockProject = MockProject() + mockProject.__setattr__("data", data) + status = {} + for entry in content: + item = NWItem(mockProject) + item.unpack(entry) + status[item.itemHandle] = item.getImportStatus(incIcon=False)[0] + packedContent.append(item.pack()) + + assert status == { + "7031beac91f75": "Started", + "53b69b83cdafc": "Started", + "974e400180a99": "New", + "edca4be2fcaf8": "New", + "e7ded148d6e4a": "1st Draft", + "6a2d6d5f4f401": "Notes", + "636b6aa9b697b": "1st Draft", + "bc0cbd2a407f3": "1st Draft", + "ba8a28a246524": "New", + "96b68994dfa3d": "2nd Draft", + "88706ddc78b1b": "1st Draft", + "ae7339df26ded": "1st Draft", + "f6622b4617424": "None", + "f7e2d9f330615": "None", + "14298de4d9524": "Minor", + "bb2c23b3c42cc": "Major", + "15c4492bd5107": "None", + "b3e74dbc1f584": "Main", + "f1471bef9f2ae": "Minor", + "5eaea4e8cdee8": "Major", + "6827118336ac1": "New", # Is now treated as novel-like + "ae9bf3c3ea159": "New", # Is now treated as novel-like + "8a5deb88c0e97": "1st Draft", + "98acd8c76c93a": "None", + "b8136a5a774a0": "New", + } + + # Save the project again, which should produce an identical project xml + timeStamp = int(datetime.fromisoformat(xmlReader.timeStamp).timestamp()) + xmlWriter = ProjectXMLWriter(fncDir) + assert xmlWriter.write(data, packedContent, timeStamp, 1000) is True + compFile = os.path.join(refDir, "projectXML_ReadLegacy13.nwx") + assert cmpFiles(outFile, compFile) + +# END Test testCoreProjectXML_ReadLegacy13 diff --git a/tests/test_core/test_core_status.py b/tests/test_core/test_core_status.py index 868a3034..760f31f5 100644 --- a/tests/test_core/test_core_status.py +++ b/tests/test_core/test_core_status.py @@ -20,23 +20,21 @@ along with this program. If not, see . """ import pytest -import random -from lxml import etree +from tools import C from PyQt5.QtGui import QIcon from novelwriter.core.status import NWStatus -statusKeys = ["sa3b179", "s1c8031", "s06671a", "sbdd640"] -importKeys = ["i466852", "i3eb13b", "i392456", "i23b8c1"] +statusKeys = [C.sNew, C.sNote, C.sDraft, C.sFinished] +importKeys = [C.iNew, C.iMinor, C.iMajor, C.iMain] @pytest.mark.core -def testCoreStatus_Internal(): +def testCoreStatus_Internal(mockRnd): """Test all the internal functions of the NWStatus class. """ - random.seed(42) theStatus = NWStatus(NWStatus.STATUS) theImport = NWStatus(NWStatus.IMPORT) @@ -87,10 +85,9 @@ def testCoreStatus_Internal(): @pytest.mark.core -def testCoreStatus_Iterator(): +def testCoreStatus_Iterator(mockRnd): """Test the iterator functions of the NWStatus class. """ - random.seed(42) theStatus = NWStatus(NWStatus.STATUS) theStatus.write(None, "New", (100, 100, 100)) @@ -132,10 +129,9 @@ def testCoreStatus_Iterator(): @pytest.mark.core -def testCoreStatus_Entries(): +def testCoreStatus_Entries(mockRnd): """Test all the simple setters for the NWStatus class. """ - random.seed(42) theStatus = NWStatus(NWStatus.STATUS) # Write @@ -161,14 +157,6 @@ def testCoreStatus_Entries(): assert theStatus[statusKeys[3]]["name"] == "Entry 4" assert theStatus[statusKeys[3]]["cols"] == (100, 100, 100) - # Check reverse map - assert theStatus._reverse == { - "Entry 1": statusKeys[0], - "Entry 2": statusKeys[1], - "Entry 3": statusKeys[2], - "Entry 4": statusKeys[3], - } - # Check # ===== @@ -176,14 +164,8 @@ def testCoreStatus_Entries(): for key in statusKeys: assert theStatus.check(key) == key - # Reverse map lookup - assert theStatus.check("Entry 1") == statusKeys[0] - assert theStatus.check("Entry 2") == statusKeys[1] - assert theStatus.check("Entry 3") == statusKeys[2] - assert theStatus.check("Entry 4") == statusKeys[3] - # Non-existing name - assert theStatus.check("Entry 5") == statusKeys[0] + assert theStatus.check("s987654") == statusKeys[0] # Name Access # =========== @@ -314,10 +296,9 @@ def testCoreStatus_Entries(): @pytest.mark.core -def testCoreStatus_XMLPackUnpack(): - """Test all the XML pack/unpack of the NWStatus class. +def testCoreStatus_PackUnpack(mockRnd): + """Test all the pack/unpack of the NWStatus class. """ - random.seed(42) theStatus = NWStatus(NWStatus.STATUS) theStatus.write(None, "New", (100, 100, 100)) theStatus.write(None, "Note", (200, 50, 0)) @@ -329,36 +310,59 @@ def testCoreStatus_XMLPackUnpack(): for _ in range(n): theStatus.increment(statusKeys[i]) - nwXML = etree.Element("novelWriterXML") - # Pack - xStatus = etree.SubElement(nwXML, "status") - theStatus.packXML(xStatus) - assert etree.tostring(xStatus, pretty_print=False, encoding="utf-8") == ( - b'' - b'New' - b'Note' - b'Draft' - b'Finished' - b'' - ) + assert list(theStatus.pack()) == [ + ("New", { + "key": statusKeys[0], + "count": "3", + "red": "100", + "green": "100", + "blue": "100" + }), + ("Note", { + "key": statusKeys[1], + "count": "5", + "red": "200", + "green": "50", + "blue": "0" + }), + ("Draft", { + "key": statusKeys[2], + "count": "7", + "red": "200", + "green": "150", + "blue": "0" + }), + ("Finished", { + "key": statusKeys[3], + "count": "9", + "red": "50", + "green": "200", + "blue": "0" + }), + ] # Unpack theStatus = NWStatus(NWStatus.STATUS) - assert theStatus.unpackXML(xStatus) + assert theStatus.unpack({ + statusKeys[0]: {"label": "New0", "colour": (100, 100, 100), "count": countTo[0]}, + statusKeys[1]: {"label": "New1", "colour": (150, 150, 150), "count": countTo[1]}, + statusKeys[2]: {"label": "New2", "colour": (200, 200, 200), "count": countTo[2]}, + statusKeys[3]: {"label": "New3", "colour": (250, 250, 250), "count": countTo[3]}, + }) assert len(theStatus._store) == 4 assert list(theStatus._store.keys()) == statusKeys - assert theStatus._store[statusKeys[0]]["name"] == "New" - assert theStatus._store[statusKeys[1]]["name"] == "Note" - assert theStatus._store[statusKeys[2]]["name"] == "Draft" - assert theStatus._store[statusKeys[3]]["name"] == "Finished" + assert theStatus._store[statusKeys[0]]["name"] == "New0" + assert theStatus._store[statusKeys[1]]["name"] == "New1" + assert theStatus._store[statusKeys[2]]["name"] == "New2" + assert theStatus._store[statusKeys[3]]["name"] == "New3" assert theStatus._store[statusKeys[0]]["cols"] == (100, 100, 100) - assert theStatus._store[statusKeys[1]]["cols"] == (200, 50, 0) - assert theStatus._store[statusKeys[2]]["cols"] == (200, 150, 0) - assert theStatus._store[statusKeys[3]]["cols"] == (50, 200, 0) + assert theStatus._store[statusKeys[1]]["cols"] == (150, 150, 150) + assert theStatus._store[statusKeys[2]]["cols"] == (200, 200, 200) + assert theStatus._store[statusKeys[3]]["cols"] == (250, 250, 250) assert theStatus._store[statusKeys[0]]["count"] == countTo[0] assert theStatus._store[statusKeys[1]]["count"] == countTo[1] assert theStatus._store[statusKeys[2]]["count"] == countTo[2] assert theStatus._store[statusKeys[3]]["count"] == countTo[3] -# END Test testCoreStatus_XMLPackUnpack +# END Test testCoreStatus_PackUnpack diff --git a/tests/test_core/test_core_tokenizer.py b/tests/test_core/test_core_tokenizer.py index 38121268..a1cc796f 100644 --- a/tests/test_core/test_core_tokenizer.py +++ b/tests/test_core/test_core_tokenizer.py @@ -22,7 +22,7 @@ along with this program. If not, see . import os import pytest -from tools import readFile +from tools import C, buildTestProject, readFile from novelwriter.core.project import NWProject from novelwriter.core.document import NWDoc @@ -133,21 +133,20 @@ def testCoreToken_Setters(mockGUI): @pytest.mark.core -def testCoreToken_TextOps(monkeypatch, nwMinimal, mockGUI): +def testCoreToken_TextOps(monkeypatch, mockGUI, mockRnd, fncDir): """Test handling files and text in the Tokenizer class. """ theProject = NWProject(mockGUI) - theProject.projLang = "en" + mockRnd.reset() + buildTestProject(theProject, fncDir) + + theProject.data.setLanguage("en") theProject._loadProjectLocalisation() theToken = BareTokenizer(theProject) theToken.setKeepMarkdown(True) - assert theProject.openProject(nwMinimal) - sHandle = "8c659a11cd429" - # Set some content to work with - docText = ( "### Scene Six\n\n" "This is text with _italic text_, some **bold text**, some ~~deleted text~~, " @@ -157,26 +156,26 @@ def testCoreToken_TextOps(monkeypatch, nwMinimal, mockGUI): ) docTextR = docText.replace("", "this").replace("", "that") - nDoc = NWDoc(theProject, sHandle) + nDoc = NWDoc(theProject, C.hSceneDoc) assert nDoc.writeDocument(docText) - theProject.setAutoReplace({"A": "this", "B": "that"}) + theProject.data.setAutoReplace({"A": "this", "B": "that"}) assert theProject.saveProject() # Root Heading assert theToken.addRootHeading("stuff") is False - assert theToken.addRootHeading(sHandle) is False + assert theToken.addRootHeading(C.hSceneDoc) is False # First Page - assert theToken.addRootHeading("7695ce551d265") is True + assert theToken.addRootHeading(C.hPlotRoot) is True assert theToken.theMarkdown[-1] == "# Notes: Plot\n\n" assert theToken._theTokens[-1] == ( Tokenizer.T_TITLE, 0, "Notes: Plot", None, Tokenizer.A_CENTRE ) # Not First Page - assert theToken.addRootHeading("7695ce551d265") is True + assert theToken.addRootHeading(C.hPlotRoot) is True assert theToken.theMarkdown[-1] == "# Notes: Plot\n\n" assert theToken._theTokens[-1] == ( Tokenizer.T_TITLE, 0, "Notes: Plot", None, Tokenizer.A_CENTRE | Tokenizer.A_PBB @@ -184,18 +183,18 @@ def testCoreToken_TextOps(monkeypatch, nwMinimal, mockGUI): # Set Text assert theToken.setText("stuff") is False - assert theToken.setText(sHandle) is True + assert theToken.setText(C.hSceneDoc) is True assert theToken._theText == docText with monkeypatch.context() as mp: mp.setattr("novelwriter.constants.nwConst.MAX_DOCSIZE", 100) - assert theToken.setText(sHandle, docText) is True + assert theToken.setText(C.hSceneDoc, docText) is True assert theToken._theText == ( "# ERROR\n\n" "Document 'New Scene' is too big (0.00 MB). Skipping.\n\n" ) - assert theToken.setText(sHandle, docText) is True + assert theToken.setText(C.hSceneDoc, docText) is True assert theToken._theText == docText assert theToken._isNone is False @@ -212,7 +211,7 @@ def testCoreToken_TextOps(monkeypatch, nwMinimal, mockGUI): assert theToken.theResult == "This is text with escapes: ** ~~ __" # Save File - savePath = os.path.join(nwMinimal, "dump.nwd") + savePath = os.path.join(fncDir, "dump.nwd") theToken.saveRawMarkdown(savePath) assert readFile(savePath) == ( "# Notes: Plot\n\n" @@ -884,7 +883,7 @@ def testCoreToken_ProcessHeaders(mockGUI): """Test the header and page parser of the Tokenizer class. """ theProject = NWProject(mockGUI) - theProject.projLang = "en" + theProject.data.setLanguage("en") theProject._loadProjectLocalisation() theToken = BareTokenizer(theProject) diff --git a/tests/test_core/test_core_tree.py b/tests/test_core/test_core_tree.py index 588d23f5..518122e7 100644 --- a/tests/test_core/test_core_tree.py +++ b/tests/test_core/test_core_tree.py @@ -23,8 +23,6 @@ import os import pytest import random -from lxml import etree - from tools import readFile from novelwriter.enum import nwItemClass, nwItemType, nwItemLayout @@ -198,7 +196,7 @@ def testCoreTree_BuildTree(mockGUI, mockItems): assert len(theTree) == len(mockItems) + 1 theList = theTree.handles() - nHandle = "0000000000010" + nHandle = "0000000000000" assert theList[-1] == nHandle # Try to add existing handle @@ -395,64 +393,6 @@ def testCoreTree_Reorder(mockGUI, mockItems): # END Test testCoreTree_Reorder -@pytest.mark.core -def testCoreTree_XMLPackUnpack(mockGUI, mockItems): - """Test packing and unpacking the tree to and from XML. - """ - theProject = NWProject(mockGUI) - theTree = NWTree(theProject) - - for tHandle, pHandle, nwItem in mockItems: - theTree.append(tHandle, pHandle, nwItem) - theTree.updateItemData(tHandle) - - assert len(theTree) == len(mockItems) - - nwXML = etree.Element("novelWriterXML") - theTree.packXML(nwXML) - assert etree.tostring(nwXML, pretty_print=False, encoding="utf-8") == ( - b'' - b'' - b'Novel' - b'Act One' - b'Chapter One' - b'Scene One' - b'Outtakes' - b'Trash' - b'Characters' - b'Jane Doe' - b'' - b'' - ) - - theTree.clear() - assert len(theTree) == 0 - assert not theTree.unpackXML(nwXML) - assert theTree.unpackXML(nwXML[0]) - assert len(theTree) == len(mockItems) - -# END Test testCoreTree_XMLPackUnpack - - @pytest.mark.core def testCoreTree_ToCFile(monkeypatch, mockGUI, mockItems, tmpDir): """Test writing the ToC.txt file. diff --git a/tests/test_dialogs/test_dlg_projdetails.py b/tests/test_dialogs/test_dlg_projdetails.py index 84360d11..c7d86670 100644 --- a/tests/test_dialogs/test_dlg_projdetails.py +++ b/tests/test_dialogs/test_dlg_projdetails.py @@ -55,7 +55,7 @@ def testDlgProjDetails_Dialog(qtbot, nwGUI, nwLipsum): assert projDet.tabMain.wordCountVal.text() == f"{3000:n}" assert projDet.tabMain.chapCountVal.text() == f"{3:n}" assert projDet.tabMain.sceneCountVal.text() == f"{5:n}" - assert projDet.tabMain.revCountVal.text() == f"{nwGUI.theProject.saveCount:n}" + assert projDet.tabMain.revCountVal.text() == f"{nwGUI.theProject.data.saveCount:n}" assert projDet.tabMain.projPathVal.text() == nwLipsum diff --git a/tests/test_dialogs/test_dlg_projload.py b/tests/test_dialogs/test_dlg_projload.py index 6e81ffe2..7ccf683a 100644 --- a/tests/test_dialogs/test_dlg_projload.py +++ b/tests/test_dialogs/test_dlg_projload.py @@ -22,7 +22,7 @@ along with this program. If not, see . import pytest import os -from tools import getGuiItem +from tools import buildTestProject, getGuiItem from PyQt5.QtCore import Qt from PyQt5.QtWidgets import ( @@ -33,10 +33,10 @@ from novelwriter.dialogs.projload import GuiProjectLoad @pytest.mark.gui -def testDlgLoadProject_Main(qtbot, monkeypatch, nwGUI, nwMinimal): +def testDlgLoadProject_Main(qtbot, monkeypatch, nwGUI, fncProj): """Test the load project wizard. """ - assert nwGUI.openProject(nwMinimal) + buildTestProject(nwGUI, fncProj) assert nwGUI.closeProject() monkeypatch.setattr(GuiProjectLoad, "exec_", lambda *a: None) @@ -87,10 +87,10 @@ def testDlgLoadProject_Main(qtbot, monkeypatch, nwGUI, nwMinimal): nwLoad._doDeleteRecent() assert nwLoad.listBox.topLevelItemCount() == recentCount - 1 - getFile = os.path.join(nwMinimal, "nwProject.nwx") + getFile = os.path.join(fncProj, "nwProject.nwx") monkeypatch.setattr(QFileDialog, "getOpenFileName", lambda *a, **k: (getFile, None)) qtbot.mouseClick(nwLoad.browseButton, Qt.LeftButton) - assert nwLoad.openPath == nwMinimal + assert nwLoad.openPath == fncProj assert nwLoad.openState == nwLoad.OPEN_STATE nwLoad.close() diff --git a/tests/test_dialogs/test_dlg_projsettings.py b/tests/test_dialogs/test_dlg_projsettings.py index bb38f317..085e5398 100644 --- a/tests/test_dialogs/test_dlg_projsettings.py +++ b/tests/test_dialogs/test_dlg_projsettings.py @@ -50,7 +50,7 @@ def testDlgProjSettings_Dialog(qtbot, monkeypatch, nwGUI): # Pretend we have a project nwGUI.hasProject = True - nwGUI.theProject.setSpellLang("en") + nwGUI.theProject.data.setSpellLang("en") # Get the dialog object nwGUI.mainMenu.aProjectSettings.activate(QAction.Trigger) @@ -95,9 +95,9 @@ def testDlgProjSettings_Main(qtbot, monkeypatch, nwGUI, fncDir, fncProj, mockRnd # Set some values theProject = nwGUI.theProject - theProject.setSpellLang("en") - theProject.setBookAuthors("Jane Smith\nJohn Smith") - theProject.setAutoReplace({"A": "B", "C": "D"}) + theProject.data.setSpellLang("en") + theProject.data.setAuthors("Jane Smith\nJohn Smith") + theProject.data.setAutoReplace({"A": "B", "C": "D"}) # Create Dialog projSettings = GuiProjectSettings(nwGUI, GuiProjectSettings.TAB_MAIN) @@ -136,9 +136,9 @@ def testDlgProjSettings_Main(qtbot, monkeypatch, nwGUI, fncDir, fncProj, mockRnd assert projSettings.spellChanged is False projSettings._doSave() - assert theProject.projName == "Project Name" - assert theProject.bookTitle == "Project Title" - assert theProject.bookAuthors == ["Jane Doe", "John Doh"] + assert theProject.data.name == "Project Name" + assert theProject.data.title == "Project Title" + assert theProject.data.authors == ["Jane Doe", "John Doh"] # Clean up projSettings._doClose() @@ -330,13 +330,13 @@ def testDlgProjSettings_StatusImport(qtbot, monkeypatch, nwGUI, fncDir, fncProj, # Check Project projSettings._doSave() - statusItems = dict(theProject.statusItems.items()) + statusItems = dict(theProject.data.itemStatus.items()) assert statusItems[C.sNew]["name"] == "New" assert statusItems[C.sDraft]["name"] == "Draft" assert statusItems[C.sFinished]["name"] == "Finished" assert statusItems["s000013"]["name"] == "Final" - importItems = dict(theProject.importItems.items()) + importItems = dict(theProject.data.itemImport.items()) assert importItems[C.iNew]["name"] == "New" assert importItems[C.iMajor]["name"] == "Major" assert importItems[C.iMain]["name"] == "Main" @@ -365,9 +365,9 @@ def testDlgProjSettings_Replace(qtbot, monkeypatch, nwGUI, fncDir, fncProj, mock # Set some values theProject = nwGUI.theProject - theProject.autoReplace = { + theProject.data.setAutoReplace({ "A": "B", "C": "D" - } + }) # Create Dialog projSettings = GuiProjectSettings(nwGUI, GuiProjectSettings.TAB_REPLACE) @@ -429,7 +429,7 @@ def testDlgProjSettings_Replace(qtbot, monkeypatch, nwGUI, fncDir, fncProj, mock # Check Project projSettings._doSave() - assert theProject.autoReplace == { + assert theProject.data.autoReplace == { "A": "B", "C": "D", "This": "With This Stuff" } diff --git a/tests/test_dialogs/test_dlg_wordlist.py b/tests/test_dialogs/test_dlg_wordlist.py index 24d61157..3a088934 100644 --- a/tests/test_dialogs/test_dlg_wordlist.py +++ b/tests/test_dialogs/test_dlg_wordlist.py @@ -25,7 +25,7 @@ import pytest from PyQt5.QtCore import Qt from PyQt5.QtWidgets import QDialog, QAction -from tools import writeFile, readFile, getGuiItem +from tools import buildTestProject, writeFile, readFile, getGuiItem from mock import causeOSError from novelwriter.constants import nwFiles @@ -33,16 +33,18 @@ from novelwriter.dialogs.wordlist import GuiWordList @pytest.mark.gui -def testDlgWordList_Dialog(qtbot, monkeypatch, nwGUI, nwMinimal): +def testDlgWordList_Dialog(qtbot, monkeypatch, nwGUI, fncProj): """test the word list editor. """ + buildTestProject(nwGUI, fncProj) + monkeypatch.setattr(GuiWordList, "exec_", lambda *a: None) monkeypatch.setattr(GuiWordList, "result", lambda *a: QDialog.Accepted) monkeypatch.setattr(GuiWordList, "accept", lambda *a: None) # Open project - nwGUI.openProject(nwMinimal) - dictFile = os.path.join(nwMinimal, "meta", nwFiles.PROJ_DICT) + nwGUI.openProject(fncProj) + dictFile = os.path.join(fncProj, "meta", nwFiles.PROJ_DICT) # Load the dialog nwGUI.mainMenu.aEditWordList.activate(QAction.Trigger) diff --git a/tests/test_gui/test_gui_doceditor.py b/tests/test_gui/test_gui_doceditor.py index 22a960c2..193fda9e 100644 --- a/tests/test_gui/test_gui_doceditor.py +++ b/tests/test_gui/test_gui_doceditor.py @@ -22,6 +22,7 @@ along with this program. If not, see . import pytest from mock import causeOSError +from tools import C, buildTestProject from PyQt5.QtCore import Qt from PyQt5.QtGui import QTextBlock, QTextCursor, QTextOption @@ -36,12 +37,12 @@ KEY_DELAY = 1 @pytest.mark.gui -def testGuiEditor_Init(qtbot, nwGUI, nwMinimal, ipsumText): +def testGuiEditor_Init(qtbot, nwGUI, fncProj, ipsumText, mockRnd): """Test initialising the editor. """ # Open project - assert nwGUI.openProject(nwMinimal) - assert nwGUI.openDocument("8c659a11cd429") + buildTestProject(nwGUI, fncProj) + assert nwGUI.openDocument(C.hSceneDoc) nwGUI.docEditor.setText("### Lorem Ipsum\n\n%s" % ipsumText[0]) assert nwGUI.saveDocument() @@ -79,13 +80,11 @@ def testGuiEditor_Init(qtbot, nwGUI, nwMinimal, ipsumText): @pytest.mark.gui -def testGuiEditor_LoadText(qtbot, monkeypatch, caplog, nwGUI, nwMinimal, ipsumText): +def testGuiEditor_LoadText(qtbot, monkeypatch, caplog, nwGUI, fncProj, ipsumText, mockRnd): """Test loading text into the editor. """ - # Open project - sHandle = "8c659a11cd429" - assert nwGUI.openProject(nwMinimal) is True - assert nwGUI.openDocument(sHandle) is True + buildTestProject(nwGUI, fncProj) + assert nwGUI.openDocument(C.hSceneDoc) is True longText = "### Lorem Ipsum\n\n%s" % "\n\n".join(ipsumText*20) nwGUI.docEditor.replaceText(longText) @@ -101,11 +100,11 @@ def testGuiEditor_LoadText(qtbot, monkeypatch, caplog, nwGUI, nwMinimal, ipsumTe # Document too big with monkeypatch.context() as mp: mp.setattr("novelwriter.constants.nwConst.MAX_DOCSIZE", 100) - assert nwGUI.docEditor.loadText(sHandle) is False + assert nwGUI.docEditor.loadText(C.hSceneDoc) is False assert "The document you are trying to open is too big." in caplog.text # Regular open - assert nwGUI.docEditor.loadText(sHandle) is True + assert nwGUI.docEditor.loadText(C.hSceneDoc) is True assert nwGUI.docEditor._bigDoc is False # Reload too big text @@ -116,18 +115,18 @@ def testGuiEditor_LoadText(qtbot, monkeypatch, caplog, nwGUI, nwMinimal, ipsumTe # Big doc handling nwGUI.mainConf.bigDocLimit = 50 - assert nwGUI.docEditor.loadText(sHandle) is True + assert nwGUI.docEditor.loadText(C.hSceneDoc) is True assert nwGUI.docEditor._bigDoc is True # Regular open, with line number - assert nwGUI.docEditor.loadText(sHandle, tLine=4) is True + assert nwGUI.docEditor.loadText(C.hSceneDoc, tLine=4) is True cursPos = nwGUI.docEditor.getCursorPosition() assert nwGUI.docEditor.document().findBlock(cursPos).blockNumber() == 4 # Load empty document nwGUI.docEditor.replaceText("") assert nwGUI.saveDocument() is True - assert nwGUI.docEditor.loadText(sHandle) is True + assert nwGUI.docEditor.loadText(C.hSceneDoc) is True assert nwGUI.docEditor.toPlainText() == "" # qtbot.stop() @@ -136,13 +135,11 @@ def testGuiEditor_LoadText(qtbot, monkeypatch, caplog, nwGUI, nwMinimal, ipsumTe @pytest.mark.gui -def testGuiEditor_SaveText(qtbot, monkeypatch, caplog, nwGUI, nwMinimal, ipsumText): +def testGuiEditor_SaveText(qtbot, monkeypatch, caplog, nwGUI, fncProj, ipsumText, mockRnd): """Test saving text from the editor. """ - # Open project - sHandle = "8c659a11cd429" - assert nwGUI.openProject(nwMinimal) is True - assert nwGUI.openDocument(sHandle) is True + buildTestProject(nwGUI, fncProj) + assert nwGUI.openDocument(C.hSceneDoc) is True # Save Text # ========= @@ -159,7 +156,7 @@ def testGuiEditor_SaveText(qtbot, monkeypatch, caplog, nwGUI, nwMinimal, ipsumTe # Unkown handle nwGUI.docEditor._docHandle = "0123456789abcdef" assert nwGUI.docEditor.saveText() is False - nwGUI.docEditor._docHandle = sHandle + nwGUI.docEditor._docHandle = C.hSceneDoc # Cause error when saving with monkeypatch.context() as mp: @@ -168,10 +165,10 @@ def testGuiEditor_SaveText(qtbot, monkeypatch, caplog, nwGUI, nwMinimal, ipsumTe assert "Could not save document." in caplog.text # Change header level - assert nwGUI.theProject.tree[sHandle].itemLayout == nwItemLayout.DOCUMENT + assert nwGUI.theProject.tree[C.hSceneDoc].itemLayout == nwItemLayout.DOCUMENT nwGUI.docEditor.replaceText(longText[1:]) assert nwGUI.docEditor.saveText() is True - assert nwGUI.theProject.tree[sHandle].itemLayout == nwItemLayout.DOCUMENT + assert nwGUI.theProject.tree[C.hSceneDoc].itemLayout == nwItemLayout.DOCUMENT # Regular save assert nwGUI.docEditor.saveText() is True @@ -182,13 +179,11 @@ def testGuiEditor_SaveText(qtbot, monkeypatch, caplog, nwGUI, nwMinimal, ipsumTe @pytest.mark.gui -def testGuiEditor_MetaData(qtbot, nwGUI, nwMinimal): +def testGuiEditor_MetaData(qtbot, nwGUI, fncProj, mockRnd): """Test extracting various meta data and other values. """ - # Open project - sHandle = "8c659a11cd429" - assert nwGUI.openProject(nwMinimal) is True - assert nwGUI.openDocument(sHandle) is True + buildTestProject(nwGUI, fncProj) + assert nwGUI.openDocument(C.hSceneDoc) is True # Get Text # This should replace line and paragraph separators, but preserve @@ -203,7 +198,7 @@ def testGuiEditor_MetaData(qtbot, nwGUI, nwMinimal): # Check Propertoes assert nwGUI.docEditor.docChanged() is True - assert nwGUI.docEditor.docHandle() == sHandle + assert nwGUI.docEditor.docHandle() == C.hSceneDoc assert nwGUI.docEditor.lastActive() > 0.0 assert nwGUI.docEditor.isEmpty() is False @@ -211,9 +206,9 @@ def testGuiEditor_MetaData(qtbot, nwGUI, nwMinimal): assert nwGUI.docEditor.setCursorPosition(None) is False assert nwGUI.docEditor.setCursorPosition(10) is True assert nwGUI.docEditor.getCursorPosition() == 10 - assert nwGUI.theProject.tree[sHandle].cursorPos != 10 + assert nwGUI.theProject.tree[C.hSceneDoc].cursorPos != 10 nwGUI.docEditor.saveCursorPosition() - assert nwGUI.theProject.tree[sHandle].cursorPos == 10 + assert nwGUI.theProject.tree[C.hSceneDoc].cursorPos == 10 assert nwGUI.docEditor.setCursorLine(None) is False assert nwGUI.docEditor.setCursorLine(2) is True @@ -231,16 +226,14 @@ def testGuiEditor_MetaData(qtbot, nwGUI, nwMinimal): @pytest.mark.gui -def testGuiEditor_Actions(qtbot, nwGUI, nwMinimal, ipsumText): +def testGuiEditor_Actions(qtbot, nwGUI, fncProj, ipsumText, mockRnd): """Test the document actions. This is not an extensive test of the action features, just that the actions are actually called. The various action features are tested when their respective functions are tested. """ - # Open project - sHandle = "8c659a11cd429" - assert nwGUI.openProject(nwMinimal) is True - assert nwGUI.openDocument(sHandle) is True + buildTestProject(nwGUI, fncProj) + assert nwGUI.openDocument(C.hSceneDoc) is True theText = "### A Scene\n\n%s" % "\n\n".join(ipsumText) assert nwGUI.docEditor.replaceText(theText) is True @@ -452,7 +445,7 @@ def testGuiEditor_Actions(qtbot, nwGUI, nwMinimal, ipsumText): # No Document Handle nwGUI.docEditor._docHandle = None assert nwGUI.docEditor.docAction(nwDocAction.BLOCK_TXT) is False - nwGUI.docEditor._docHandle = sHandle + nwGUI.docEditor._docHandle = C.hSceneDoc # Wrong Action Type assert nwGUI.docEditor.docAction(None) is False @@ -466,13 +459,11 @@ def testGuiEditor_Actions(qtbot, nwGUI, nwMinimal, ipsumText): @pytest.mark.gui -def testGuiEditor_Insert(qtbot, monkeypatch, nwGUI, nwMinimal, ipsumText): +def testGuiEditor_Insert(qtbot, monkeypatch, nwGUI, fncProj, ipsumText, mockRnd): """Test the document insert functions. """ - # Open project - sHandle = "8c659a11cd429" - assert nwGUI.openProject(nwMinimal) is True - assert nwGUI.openDocument(sHandle) is True + buildTestProject(nwGUI, fncProj) + assert nwGUI.openDocument(C.hSceneDoc) is True theText = "### A Scene\n\n%s" % "\n\n".join(ipsumText) assert nwGUI.docEditor.replaceText(theText) is True @@ -487,7 +478,7 @@ def testGuiEditor_Insert(qtbot, monkeypatch, nwGUI, nwMinimal, ipsumText): nwGUI.docEditor._docHandle = None assert nwGUI.docEditor.setCursorPosition(24) is True assert nwGUI.docEditor.insertText("Stuff") is False - nwGUI.docEditor._docHandle = sHandle + nwGUI.docEditor._docHandle = C.hSceneDoc # Insert String assert nwGUI.docEditor.setCursorPosition(24) is True @@ -551,13 +542,11 @@ def testGuiEditor_Insert(qtbot, monkeypatch, nwGUI, nwMinimal, ipsumText): @pytest.mark.gui -def testGuiEditor_TextManipulation(qtbot, monkeypatch, nwGUI, nwMinimal, ipsumText): +def testGuiEditor_TextManipulation(qtbot, monkeypatch, nwGUI, fncProj, ipsumText, mockRnd): """Test the text manipulation functions. """ - # Open project - sHandle = "8c659a11cd429" - assert nwGUI.openProject(nwMinimal) is True - assert nwGUI.openDocument(sHandle) is True + buildTestProject(nwGUI, fncProj) + assert nwGUI.openDocument(C.hSceneDoc) is True theText = "### A Scene\n\n%s" % "\n\n".join(ipsumText) assert nwGUI.docEditor.replaceText(theText) is True @@ -760,13 +749,11 @@ def testGuiEditor_TextManipulation(qtbot, monkeypatch, nwGUI, nwMinimal, ipsumTe @pytest.mark.gui -def testGuiEditor_BlockFormatting(qtbot, monkeypatch, nwGUI, nwMinimal, ipsumText): +def testGuiEditor_BlockFormatting(qtbot, monkeypatch, nwGUI, fncProj, ipsumText, mockRnd): """Test the block formatting function. """ - # Open project - sHandle = "8c659a11cd429" - assert nwGUI.openProject(nwMinimal) is True - assert nwGUI.openDocument(sHandle) is True + buildTestProject(nwGUI, fncProj) + assert nwGUI.openDocument(C.hSceneDoc) is True theText = "### A Scene\n\n%s" % "\n\n".join(ipsumText) assert nwGUI.docEditor.replaceText(theText) is True @@ -1075,13 +1062,11 @@ def testGuiEditor_BlockFormatting(qtbot, monkeypatch, nwGUI, nwMinimal, ipsumTex @pytest.mark.gui -def testGuiEditor_Tags(qtbot, nwGUI, nwMinimal, ipsumText): +def testGuiEditor_Tags(qtbot, nwGUI, fncProj, ipsumText, mockRnd): """Test the document editor tags functionality. """ - # Open project - sHandle = "8c659a11cd429" - assert nwGUI.openProject(nwMinimal) is True - assert nwGUI.openDocument(sHandle) is True + buildTestProject(nwGUI, fncProj) + assert nwGUI.openDocument(C.hSceneDoc) is True # Create Scene theText = "### A Scene\n\n@char: Jane, John\n\n" + ipsumText[0] + "\n\n" @@ -1089,7 +1074,7 @@ def testGuiEditor_Tags(qtbot, nwGUI, nwMinimal, ipsumText): # Create Character theText = "### Jane Doe\n\n@tag: Jane\n\n" + ipsumText[1] + "\n\n" - cHandle = nwGUI.theProject.newFile("Jane Doe", "afb3043c7b2b3") + cHandle = nwGUI.theProject.newFile("Jane Doe", C.hCharRoot) assert nwGUI.openDocument(cHandle) is True assert nwGUI.docEditor.replaceText(theText) is True assert nwGUI.saveDocument() is True @@ -1098,7 +1083,7 @@ def testGuiEditor_Tags(qtbot, nwGUI, nwMinimal, ipsumText): # Follow Tag # ========== - assert nwGUI.openDocument(sHandle) is True + assert nwGUI.openDocument(C.hSceneDoc) is True # Empty Block assert nwGUI.docEditor.setCursorLine(1) is True @@ -1136,7 +1121,7 @@ def testGuiEditor_Tags(qtbot, nwGUI, nwMinimal, ipsumText): @pytest.mark.gui -def testGuiEditor_WordCounters(qtbot, monkeypatch, nwGUI, nwMinimal, ipsumText): +def testGuiEditor_WordCounters(qtbot, monkeypatch, nwGUI, fncProj, ipsumText, mockRnd): """Test saving text from the editor. """ class MockThreadPool: @@ -1153,7 +1138,8 @@ def testGuiEditor_WordCounters(qtbot, monkeypatch, nwGUI, nwMinimal, ipsumText): nwGUI.threadPool = MockThreadPool() nwGUI.docEditor.wcTimerDoc.blockSignals(True) nwGUI.docEditor.wcTimerSel.blockSignals(True) - assert nwGUI.openProject(nwMinimal) is True + + buildTestProject(nwGUI, fncProj) # Run on an empty document nwGUI.docEditor._runDocCounter() @@ -1167,10 +1153,9 @@ def testGuiEditor_WordCounters(qtbot, monkeypatch, nwGUI, nwMinimal, ipsumText): assert nwGUI.docEditor.docFooter.wordsText.text() == "Words: 0 (+0)" # Open a document and populate it - sHandle = "8c659a11cd429" - nwGUI.theProject.tree[sHandle]._initCount = 0 # Clear item's count - nwGUI.theProject.tree[sHandle]._wordCount = 0 # Clear item's count - assert nwGUI.openDocument(sHandle) is True + nwGUI.theProject.tree[C.hSceneDoc]._initCount = 0 # Clear item's count + nwGUI.theProject.tree[C.hSceneDoc]._wordCount = 0 # Clear item's count + assert nwGUI.openDocument(C.hSceneDoc) is True theText = "\n\n".join(ipsumText) cC, wC, pC = countWords(theText) @@ -1193,9 +1178,9 @@ def testGuiEditor_WordCounters(qtbot, monkeypatch, nwGUI, nwMinimal, ipsumText): nwGUI.docEditor.wCounterDoc.run() # nwGUI.docEditor._updateDocCounts(cC, wC, pC) - assert nwGUI.theProject.tree[sHandle]._charCount == cC - assert nwGUI.theProject.tree[sHandle]._wordCount == wC - assert nwGUI.theProject.tree[sHandle]._paraCount == pC + assert nwGUI.theProject.tree[C.hSceneDoc]._charCount == cC + assert nwGUI.theProject.tree[C.hSceneDoc]._wordCount == wC + assert nwGUI.theProject.tree[C.hSceneDoc]._paraCount == pC assert nwGUI.docEditor.docFooter.wordsText.text() == f"Words: {wC} (+{wC})" # Select all text diff --git a/tests/test_gui/test_gui_guimain.py b/tests/test_gui/test_gui_guimain.py index 79fcbe52..28582695 100644 --- a/tests/test_gui/test_gui_guimain.py +++ b/tests/test_gui/test_gui_guimain.py @@ -172,10 +172,10 @@ def testGuiMain_Editing(qtbot, monkeypatch, nwGUI, fncProj, refDir, outDir, mock assert nwGUI.theProject.tree.trashRoot() is None assert nwGUI.theProject.projPath is None assert nwGUI.theProject.projMeta is None - assert nwGUI.theProject.projName == "" - assert nwGUI.theProject.bookTitle == "" - assert len(nwGUI.theProject.bookAuthors) == 0 - assert not nwGUI.theProject.spellCheck + assert nwGUI.theProject.data.name == "" + assert nwGUI.theProject.data.title == "" + assert nwGUI.theProject.data.authors == [] + assert nwGUI.theProject.data.spellCheck is False # Check the files projFile = os.path.join(fncProj, "nwProject.nwx") @@ -194,10 +194,10 @@ def testGuiMain_Editing(qtbot, monkeypatch, nwGUI, fncProj, refDir, outDir, mock assert nwGUI.theProject.tree.trashRoot() is None assert nwGUI.theProject.projPath == fncProj assert nwGUI.theProject.projMeta == os.path.join(fncProj, "meta") - assert nwGUI.theProject.projName == "New Project" - assert nwGUI.theProject.bookTitle == "New Novel" - assert len(nwGUI.theProject.bookAuthors) == 1 - assert nwGUI.theProject.spellCheck is False + assert nwGUI.theProject.data.name == "New Project" + assert nwGUI.theProject.data.title == "New Novel" + assert nwGUI.theProject.data.authors == ["Jane Doe"] + assert nwGUI.theProject.data.spellCheck is False # Check that tree items have been created assert nwGUI.projView.projTree._getTreeItem(C.hNovelRoot) is not None @@ -489,11 +489,12 @@ def testGuiMain_Editing(qtbot, monkeypatch, nwGUI, fncProj, refDir, outDir, mock # Check a Quick Create and Delete assert nwGUI.projView.projTree.newTreeItem(nwItemType.FILE, None) newHandle = nwGUI.projView.getSelectedHandle() - assert nwGUI.theProject.tree["0000000000020"] is not None + assert newHandle == "0000000000013" + assert nwGUI.theProject.tree[newHandle] is not None assert nwGUI.projView.requestDeleteItem() assert nwGUI.projView.setSelectedHandle(newHandle) assert nwGUI.projView.requestDeleteItem() - assert nwGUI.theProject.tree["0000000000024"] is not None # Trash + assert nwGUI.theProject.tree["0000000000014"] is not None # Trash assert nwGUI.saveProject() # Check the files @@ -509,21 +510,21 @@ def testGuiMain_Editing(qtbot, monkeypatch, nwGUI, fncProj, refDir, outDir, mock copyfile(projFile, testFile) assert cmpFiles(testFile, compFile) - projFile = os.path.join(fncProj, "content", "0000000000020.nwd") - testFile = os.path.join(outDir, "guiEditor_Main_Final_0000000000020.nwd") - compFile = os.path.join(refDir, "guiEditor_Main_Final_0000000000020.nwd") + projFile = os.path.join(fncProj, "content", "0000000000010.nwd") + testFile = os.path.join(outDir, "guiEditor_Main_Final_0000000000010.nwd") + compFile = os.path.join(refDir, "guiEditor_Main_Final_0000000000010.nwd") copyfile(projFile, testFile) assert cmpFiles(testFile, compFile) - projFile = os.path.join(fncProj, "content", "0000000000021.nwd") - testFile = os.path.join(outDir, "guiEditor_Main_Final_0000000000021.nwd") - compFile = os.path.join(refDir, "guiEditor_Main_Final_0000000000021.nwd") + projFile = os.path.join(fncProj, "content", "0000000000011.nwd") + testFile = os.path.join(outDir, "guiEditor_Main_Final_0000000000011.nwd") + compFile = os.path.join(refDir, "guiEditor_Main_Final_0000000000011.nwd") copyfile(projFile, testFile) assert cmpFiles(testFile, compFile) - projFile = os.path.join(fncProj, "content", "0000000000022.nwd") - testFile = os.path.join(outDir, "guiEditor_Main_Final_0000000000022.nwd") - compFile = os.path.join(refDir, "guiEditor_Main_Final_0000000000022.nwd") + projFile = os.path.join(fncProj, "content", "0000000000012.nwd") + testFile = os.path.join(outDir, "guiEditor_Main_Final_0000000000012.nwd") + compFile = os.path.join(refDir, "guiEditor_Main_Final_0000000000012.nwd") copyfile(projFile, testFile) assert cmpFiles(testFile, compFile) diff --git a/tests/test_tools/test_tools_projwizard.py b/tests/test_tools/test_tools_projwizard.py index a2facf6e..5d598e6d 100644 --- a/tests/test_tools/test_tools_projwizard.py +++ b/tests/test_tools/test_tools_projwizard.py @@ -23,7 +23,7 @@ import os import sys import pytest -from tools import getGuiItem +from tools import buildTestProject, getGuiItem from PyQt5.QtCore import Qt from PyQt5.QtWidgets import QFileDialog, QWizard, QDialog @@ -37,7 +37,7 @@ from novelwriter.tools.projwizard import ( @pytest.mark.gui @pytest.mark.skipif(sys.platform.startswith("darwin"), reason="Not running on Darwin") -def testToolProjectWizard_Handling(qtbot, monkeypatch, nwGUI, nwMinimal): +def testToolProjectWizard_Handling(qtbot, monkeypatch, nwGUI, fncProj): """Test the launch of the project wizard. Disabled for macOS because the test segfaults on QWizard.show() """ @@ -45,7 +45,7 @@ def testToolProjectWizard_Handling(qtbot, monkeypatch, nwGUI, nwMinimal): # ======================== # New with a project open should cause an error - assert nwGUI.openProject(nwMinimal) + buildTestProject(nwGUI, fncProj) with monkeypatch.context() as mp: mp.setattr(nwGUI, "closeProject", lambda *a: False) assert nwGUI.newProject() is False @@ -61,7 +61,7 @@ def testToolProjectWizard_Handling(qtbot, monkeypatch, nwGUI, nwMinimal): assert nwGUI.newProject() is False # Now, with a non-empty folder - mp.setattr(nwGUI, "showNewProjectDialog", lambda *a: {"projPath": nwMinimal}) + mp.setattr(nwGUI, "showNewProjectDialog", lambda *a: {"projPath": fncProj}) assert nwGUI.newProject() is False # Test the Wizard Launching diff --git a/tests/tools.py b/tests/tools.py index 4a97c930..0da846cc 100644 --- a/tests/tools.py +++ b/tests/tools.py @@ -167,9 +167,19 @@ def buildTestProject(theObject, projPath): theProject.clearProject() theProject.setProjectPath(projPath, newProject=True) - theProject.setProjectName("New Project") - theProject.setBookTitle("New Novel") - theProject.setBookAuthors("Jane Doe") + + theProject.data.itemStatus.write(None, "New", (100, 100, 100)) + theProject.data.itemStatus.write(None, "Note", (200, 50, 0)) + theProject.data.itemStatus.write(None, "Draft", (200, 150, 0)) + theProject.data.itemStatus.write(None, "Finished", (50, 200, 0)) + theProject.data.itemImport.write(None, "New", (100, 100, 100)) + theProject.data.itemImport.write(None, "Minor", (200, 50, 0)) + theProject.data.itemImport.write(None, "Major", (200, 150, 0)) + theProject.data.itemImport.write(None, "Main", (50, 200, 0)) + + theProject.data.setName("New Project") + theProject.data.setTitle("New Novel") + theProject.data.setAuthors("Jane Doe") # Creating a minimal project with a few root folders and a # single chapter folder with a single file. @@ -195,7 +205,7 @@ def buildTestProject(theObject, projPath): aDoc.writeDocument("### %s\n\n" % theProject.tr("New Scene")) theProject.index.reIndexHandle(xHandle[8]) - theProject.projOpened = time.time() + theProject._projOpened = time.time() theProject.setProjectChanged(True) theProject.saveProject(autoSave=True)