"""
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
from enum import Enum
from lxml import etree
from novelwriter.common import (
checkBool, checkInt, checkStringNone, simplified, checkString
)
logger = logging.getLogger(__name__)
NUM_VERSION = {
"1.0": 0x0100,
"1.1": 0x0101,
"1.2": 0x0102,
"1.3": 0x0103,
"1.4": 0x0104,
}
class XMLReadState(Enum):
NO_ACTION = 0
NO_ERROR = 1
PARSED_BACKUP = 2
CANNOT_PARSE = 3
NOT_NWX_FILE = 4
UNKNOWN_VERSION = 5
PARSING_ERROR = 6
PARSED_OK = 7
WAS_LEGACY = 8
# END Class XMLReadState
class ProjectXMLReader:
def __init__(self, path):
self._path = path
self._state = XMLReadState.NO_ACTION
self._content = []
self._statusData = {}
self._statusMap = {}
self._root = ""
self._version = 0x0000
self._appVersion = ""
self._hexVersion = ""
self._timeStamp = ""
return
##
# Properties
##
@property
def content(self):
return self._content
@property
def state(self):
return self._state
@property
def xmlRoot(self):
return self._root
@property
def xmlVersion(self):
return self._version
@property
def appVersion(self):
return self._appVersion
@property
def hexVersion(self):
return self._hexVersion
@property
def timeStamp(self):
return self._timeStamp
##
# Methods
##
def read(self, projData):
"""Read and parse the project XML file.
"""
self._content = []
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:
return False
xRoot = xml.getroot()
self._root = str(xRoot.tag)
if self._root != "novelWriterXML":
self._state = XMLReadState.NOT_NWX_FILE
return False
# Changes:
# 1.0 : Original file format.
# 1.1 : Changes the way documents are structured in the project
# folder from data_X, where X is the first hex value of
# the handle, to a single content folder.
# 1.2 : Changes the way autoReplace entries are stored. The 1.1
# parser will lose the autoReplace settings if allowed to
# read the file. Introduced in version 0.10.
# 1.3 : Reduces the number of layouts to only two. One for novel
# documents and one for project notes. Introduced in
# version 1.5.
# 1.4 : Introduces a more compact format for storing items. All
# settings aside from name are now attributes. This format
# also changes the way satus and importance labels are
# stored and handled. Introduced in version 1.7.
fileVersion = str(xRoot.attrib.get("fileVersion", ""))
if fileVersion in NUM_VERSION:
self._version = NUM_VERSION[fileVersion]
else:
self._state = XMLReadState.UNKNOWN_VERSION
return False
self._appVersion = str(xRoot.attrib.get("appVersion", ""))
self._hexVersion = str(xRoot.attrib.get("appVersion", ""))
self._timeStamp = str(xRoot.attrib.get("timeStamp", ""))
status = True
for xSection in xRoot:
if xSection.tag == "project":
status &= self._parseProjectMeta(xSection, projData)
elif xSection.tag == "settings":
status &= self._parseProjectSettings(xSection, projData)
elif xSection.tag == "content":
if self._version >= 0x0104:
status &= self._parseProjectContent(xSection)
else:
self._genLegacyImportStatysMap(projData)
status &= self._parseProjectContentLegacy(xSection)
else:
logger.warning("Ignored in xml", xSection.tag)
if not status:
self._state = XMLReadState.PARSING_ERROR
return False
if self._version == 0x0104:
self._state = XMLReadState.PARSED_OK
else:
self._state = XMLReadState.WAS_LEGACY
return True
##
# Internal Functions
##
def _parseProjectMeta(self, xSection, projData):
"""Parse the project section of the XML file.
"""
logger.debug("Parsing xml ")
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 True
def _parseProjectSettings(self, xSection, projData):
"""Parse the settings section of the XML file.
"""
logger.debug("Parsing xml ")
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 == "lastEdited":
projData.setLastHandle(xItem.text, "editor")
elif xItem.tag == "lastViewed":
projData.setLastHandle(xItem.text, "viewer")
elif xItem.tag == "lastNovel":
projData.setLastHandle(xItem.text, "noveltree")
elif xItem.tag == "lastOutline":
projData.setLastHandle(xItem.text, "outline")
elif xItem.tag == "lastWordCount":
projData.setLastCount(xItem.text, "total")
elif xItem.tag == "novelWordCount":
projData.setLastCount(xItem.text, "novel")
elif xItem.tag == "notesWordCount":
projData.setLastCount(xItem.text, "notes")
elif xItem.tag == "status":
self._parseStatusImport(xItem, projData.itemStatus)
elif xItem.tag in ("import", "importance"):
self._parseStatusImport(xItem, projData.itemImport)
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":
projData.setTitleFormat(self._parseDictTagText(xItem))
else:
logger.warning("Ignored in xml", xItem.tag)
return True
def _parseProjectContent(self, xSection):
"""Parse the content section of the XML file.
"""
logger.debug("Parsing xml ")
for xItem in xSection:
if xItem.tag == "item":
item = {}
item["handle"] = xItem.attrib.get("handle", None)
item["parent"] = xItem.attrib.get("parent", None)
item["root"] = xItem.attrib.get("root", None)
item["order"] = checkInt(xItem.attrib.get("order", 0), 0)
item["type"] = checkString(xItem.attrib.get("type", ""), "")
item["class"] = checkString(xItem.attrib.get("class", ""), "")
item["layout"] = checkString(xItem.attrib.get("layout", ""), "")
for xVal in xItem:
if xVal.tag == "meta":
item["expanded"] = checkBool(xVal.attrib.get("expanded", False), False)
item["heading"] = checkString(xVal.attrib.get("heading", "H0"), "H0")
item["charCount"] = checkInt(xVal.attrib.get("charCount", 0), 0)
item["wordCount"] = checkInt(xVal.attrib.get("wordCount", 0), 0)
item["paraCount"] = checkInt(xVal.attrib.get("paraCount", 0), 0)
item["cursorPos"] = checkInt(xVal.attrib.get("cursorPos", 0), 0)
elif xVal.tag == "name":
item["label"] = simplified(checkString(xVal.text, ""))
item["status"] = checkStringNone(xVal.attrib.get("status", None), None)
item["import"] = checkStringNone(xVal.attrib.get("import", None), None)
item["active"] = checkBool(xVal.attrib.get("active", False), False)
# ToDo: Remove before 2.0 release. Only needed for 2.0 pre-releases.
if "exported" in xVal.attrib:
item["active"] = checkBool(xVal.attrib.get("exported", False), False)
else:
logger.warning("Ignored in xml", xVal.tag)
self._content.append(item)
else:
logger.warning("Ignored item in xml", xItem.tag)
return True
def _parseProjectContentLegacy(self, xSection):
"""Parse the content section of the XML file for older version.
"""
logger.debug("Parsing xml (legacy format)")
depLayout = ("TITLE", "PAGE", "BOOK", "PARTITION", "UNNUMBERED", "CHAPTER", "SCENE")
for xItem in xSection:
item = {}
if xItem.tag == "item":
item["handle"] = xItem.attrib.get("handle", None)
item["parent"] = xItem.attrib.get("parent", None)
item["root"] = None # Value was added in 1.4
item["order"] = checkInt(xItem.attrib.get("order", 0), 0)
item["heading"] = "H0" # Value was added in 1.4
tmpStatus = ""
for xVal in xItem:
if xVal.tag == "name":
item["label"] = simplified(checkString(xVal.text, ""))
elif xVal.tag == "status":
tmpStatus = checkStringNone(xVal.text, None)
elif xVal.tag == "type":
item["type"] = checkString(xVal.text, "")
elif xVal.tag == "class":
item["class"] = checkString(xVal.text, "")
elif xVal.tag == "layout":
item["layout"] = checkString(xVal.text, "")
elif xVal.tag == "expanded":
item["expanded"] = checkBool(xVal.text, False)
elif xVal.tag == "exported": # Renamed to active in 1.4
item["active"] = checkBool(xVal.text, False)
elif xVal.tag == "charCount":
item["charCount"] = checkInt(xVal.text, 0)
elif xVal.tag == "wordCount":
item["wordCount"] = checkInt(xVal.text, 0)
elif xVal.tag == "paraCount":
item["paraCount"] = checkInt(xVal.text, 0)
elif xVal.tag == "cursorPos":
item["cursorPos"] = checkInt(xVal.text, 0)
else:
logger.warning("Ignored in xml", xVal.tag)
# Status was split into separate status/import with a key in 1.4
if item.get("class", "") in ("NOVEL", "ARCHIVE"):
item["status"] = self._statusMap.get(tmpStatus, None)
else:
item["import"] = self._importMap.get(tmpStatus, None)
# A number of layouts were removed in 1.3
if item.get("layout", "") in depLayout:
item["layout"] = "DOCUMENT"
# The trast type was removed in 1.4
if item.get("type", "") == "TRASH":
item["type"] = "ROOT"
self._content.append(item)
else:
logger.warning("Ignored in xml", xItem.tag)
return True
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 _genLegacyImportStatysMap(self, projData):
"""Generate a map of legacy import/status values.
"""
self._statusMap = {entry["name"]: key for key, entry in projData.itemStatus.items()}
self._importMap = {entry["name"]: key for key, entry in projData.itemImport.items()}
return
def _parseDictKeyText(self, xItem):
"""Parse a dictionary stored with key as an attribute and the
value as the text porperty.
"""
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
def write(self):
return
##
# Internal Functions
##
# END Class ProjectXMLWriter