1680 lines
57 KiB
Python
1680 lines
57 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""novelWriter Project Wrapper
|
||
|
||
novelWriter – Project Wrapper
|
||
===============================
|
||
Class wrapping the data if a novelWriter project
|
||
|
||
File History:
|
||
Created: 2018-09-29 [0.0.1] NWProject
|
||
Created: 2018-10-27 [0.0.1] NWItem
|
||
Created: 2019-05-19 [0.1.3] NWStatus
|
||
Merged: 2020-05-07 [0.4.5] Moved NWItem class to this file
|
||
Merged: 2020-05-07 [0.4.5] Moved NWStatus class to this file
|
||
Added: 2020-05-07 [0.4.5] NWTree
|
||
|
||
This file is a part of novelWriter
|
||
Copyright 2020, 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 <https://www.gnu.org/licenses/>.
|
||
"""
|
||
|
||
import logging
|
||
import nw
|
||
|
||
from os import path, mkdir, listdir, unlink, rename
|
||
from lxml import etree
|
||
from hashlib import sha256
|
||
from time import time
|
||
from shutil import make_archive
|
||
|
||
from PyQt5.QtWidgets import QMessageBox
|
||
|
||
from nw.gui.tools import OptionState
|
||
from nw.core.tools import projectMaintenance
|
||
from nw.core.document import NWDoc
|
||
from nw.common import checkString, checkBool, checkInt, formatTimeStamp
|
||
from nw.constants import (
|
||
nwFiles, nwItemType, nwItemClass, nwItemLayout, nwAlert
|
||
)
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
class NWProject():
|
||
|
||
def __init__(self, theParent):
|
||
|
||
# Internal
|
||
self.theParent = theParent
|
||
self.mainConf = nw.CONFIG
|
||
|
||
# Core Elements
|
||
self.optState = OptionState(self) # Project-specific GUI options
|
||
self.projTree = NWTree(self) # The project tree
|
||
|
||
# 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 = None # Meta data: number of saves
|
||
self.autoCount = None # Meta data: number of automatic saves
|
||
|
||
# Class Settings
|
||
self.projPath = None # The full path to where the currently open project is saved
|
||
self.projMeta = None # The full path to the project's meta data folder
|
||
self.projDict = None # The spell check dictionary
|
||
self.projFile = None # The file name of the project main XML file
|
||
|
||
# Project Meta
|
||
self.projName = "" # Project name (working title)
|
||
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.autoOutline = True # If true, the Project Outline is updated automatically
|
||
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.lastWCount = 0 # The project word count from last session
|
||
self.currWCount = 0 # The project word count in current session
|
||
self.doBackup = True # Run project backup on exit
|
||
|
||
# Set Defaults
|
||
self.clearProject()
|
||
|
||
# Internal Mapping
|
||
self.makeAlert = self.theParent.makeAlert
|
||
|
||
return
|
||
|
||
##
|
||
# Item Methods
|
||
##
|
||
|
||
def newRoot(self, rootName, rootClass):
|
||
"""Add a new root item. These items are unique, except for item class
|
||
CUSTOM, and always have parent handle set to None.
|
||
"""
|
||
if not self.projTree.checkRootUnique(rootClass):
|
||
self.makeAlert("Duplicate root item detected!", nwAlert.ERROR)
|
||
return None
|
||
newItem = NWItem(self)
|
||
newItem.setName(rootName)
|
||
newItem.setType(nwItemType.ROOT)
|
||
newItem.setClass(rootClass)
|
||
newItem.setStatus(0)
|
||
self.projTree.append(None, None, newItem)
|
||
return newItem.itemHandle
|
||
|
||
def newFolder(self, folderName, folderClass, pHandle):
|
||
"""Add a new folder with a given name and class and parent item.
|
||
"""
|
||
newItem = NWItem(self)
|
||
newItem.setName(folderName)
|
||
newItem.setType(nwItemType.FOLDER)
|
||
newItem.setClass(folderClass)
|
||
newItem.setStatus(0)
|
||
self.projTree.append(None, pHandle, newItem)
|
||
return newItem.itemHandle
|
||
|
||
def newFile(self, fileName, fileClass, pHandle):
|
||
"""Add a new file with a given name and class, and set a default
|
||
layout based on the class. SCENE for NOVEL, and otherwise NOTE.
|
||
"""
|
||
newItem = NWItem(self)
|
||
newItem.setName(fileName)
|
||
newItem.setType(nwItemType.FILE)
|
||
if fileClass == nwItemClass.NOVEL:
|
||
newItem.setLayout(nwItemLayout.SCENE)
|
||
else:
|
||
newItem.setLayout(nwItemLayout.NOTE)
|
||
newItem.setClass(fileClass)
|
||
newItem.setStatus(0)
|
||
self.projTree.append(None, pHandle, newItem)
|
||
return newItem.itemHandle
|
||
|
||
def addTrash(self):
|
||
"""Add the special trash root folder to the project.
|
||
"""
|
||
newItem = NWItem(self)
|
||
newItem.setName("Trash")
|
||
newItem.setType(nwItemType.TRASH)
|
||
newItem.setClass(nwItemClass.TRASH)
|
||
self.projTree.append(None, None, newItem)
|
||
return newItem.itemHandle
|
||
|
||
##
|
||
# Project Methods
|
||
##
|
||
|
||
def newProject(self):
|
||
"""Create a new project by populating the project tree with a
|
||
few starter items.
|
||
"""
|
||
hNovel = self.newRoot("Novel", nwItemClass.NOVEL)
|
||
hChars = self.newRoot("Characters", nwItemClass.CHARACTER)
|
||
hWorld = self.newRoot("Plot", nwItemClass.PLOT)
|
||
hWorld = self.newRoot("World", nwItemClass.WORLD)
|
||
hChapt = self.newFolder("New Chapter", nwItemClass.NOVEL, hNovel)
|
||
hScene = self.newFile("New Scene", nwItemClass.NOVEL, hChapt)
|
||
self.projOpened = time()
|
||
self.setProjectChanged(True)
|
||
return True
|
||
|
||
def clearProject(self):
|
||
"""Clear the data for the current project, and set them to
|
||
default values.
|
||
"""
|
||
|
||
# Project Status
|
||
self.projOpened = 0
|
||
self.projChanged = False
|
||
self.projAltered = False
|
||
self.saveCount = 0
|
||
self.autoCount = 0
|
||
|
||
# Project Tree
|
||
self.projTree.clear()
|
||
|
||
# Project Settings
|
||
self.projPath = None
|
||
self.projMeta = None
|
||
self.projDict = None
|
||
self.projFile = nwFiles.PROJ_FILE
|
||
self.projName = ""
|
||
self.bookTitle = ""
|
||
self.bookAuthors = []
|
||
self.autoReplace = {}
|
||
self.titleFormat = {
|
||
"title" : r"%title%",
|
||
"chapter" : r"Chapter %num%\\%title%",
|
||
"unnumbered" : r"%title%",
|
||
"scene" : r"* * *",
|
||
"section" : r"",
|
||
"withSynopsis" : False,
|
||
"withComments" : False,
|
||
"withKeywords" : False,
|
||
}
|
||
self.spellCheck = False
|
||
self.autoOutline = True
|
||
self.statusItems = NWStatus()
|
||
self.statusItems.addEntry("New", (100,100,100))
|
||
self.statusItems.addEntry("Note", (200, 50, 0))
|
||
self.statusItems.addEntry("Draft", (200,150, 0))
|
||
self.statusItems.addEntry("Finished",( 50,200, 0))
|
||
self.importItems = NWStatus()
|
||
self.importItems.addEntry("New", (100,100,100))
|
||
self.importItems.addEntry("Minor", (200, 50, 0))
|
||
self.importItems.addEntry("Major", (200,150, 0))
|
||
self.importItems.addEntry("Main", ( 50,200, 0))
|
||
self.lastEdited = None
|
||
self.lastViewed = None
|
||
self.lastWCount = 0
|
||
self.currWCount = 0
|
||
|
||
return
|
||
|
||
def openProject(self, fileName, overrideLock=False):
|
||
"""Open the project file provided, or if doesn't exist, assume
|
||
it is a folder, and look for the file within it. If successful,
|
||
parse the XML of the file and populate the project variables and
|
||
build the tree of project items.
|
||
"""
|
||
|
||
if not path.isfile(fileName):
|
||
fileName = path.join(fileName, nwFiles.PROJ_FILE)
|
||
if not path.isfile(fileName):
|
||
self.makeAlert("File not found: %s" % fileName, nwAlert.ERROR)
|
||
return False
|
||
|
||
self.clearProject()
|
||
self.projPath = path.abspath(path.dirname(fileName))
|
||
logger.debug("Opening project: %s" % self.projPath)
|
||
|
||
self.projMeta = path.join(self.projPath,"meta")
|
||
self.projDict = path.join(self.projMeta, nwFiles.PROJ_DICT)
|
||
|
||
if not self._checkFolder(self.projMeta):
|
||
return False
|
||
|
||
if overrideLock:
|
||
self._clearLockFile()
|
||
|
||
lockStatus = self._readLockFile()
|
||
if len(lockStatus) > 0:
|
||
if lockStatus[0] == "ERROR":
|
||
logger.warning("Failed to check lock file")
|
||
else:
|
||
logger.error("Project is locked, so not opening")
|
||
self.lockedBy = lockStatus
|
||
self.clearProject()
|
||
return False
|
||
else:
|
||
logger.verbose("Project is not locked")
|
||
|
||
try:
|
||
projectMaintenance(self)
|
||
except Exception as E:
|
||
logger.error(str(E))
|
||
|
||
try:
|
||
nwXML = etree.parse(fileName)
|
||
except Exception as e:
|
||
self.makeAlert(["Failed to parse project xml.",str(e)], nwAlert.ERROR)
|
||
|
||
# Trying to open backup file instead
|
||
backFile = fileName[:-3]+"bak"
|
||
if path.isfile(backFile):
|
||
self.makeAlert("Attempting to open backup project file instead.", nwAlert.INFO)
|
||
try:
|
||
nwXML = etree.parse(backFile)
|
||
except Exception as e:
|
||
self.makeAlert(["Failed to parse project xml.",str(e)], nwAlert.ERROR)
|
||
self.clearProject()
|
||
return False
|
||
else:
|
||
self.clearProject()
|
||
return False
|
||
|
||
xRoot = nwXML.getroot()
|
||
nwxRoot = xRoot.tag
|
||
|
||
appVersion = "Unknown"
|
||
hexVersion = "0x0"
|
||
fileVersion = "Unknown"
|
||
self.saveCount = 0
|
||
self.autoCount = 0
|
||
|
||
if "appVersion" in xRoot.attrib:
|
||
appVersion = xRoot.attrib["appVersion"]
|
||
if "hexVersion" in xRoot.attrib:
|
||
hexVersion = xRoot.attrib["hexVersion"]
|
||
if "fileVersion" in xRoot.attrib:
|
||
fileVersion = xRoot.attrib["fileVersion"]
|
||
if "saveCount" in xRoot.attrib:
|
||
self.saveCount = checkInt(xRoot.attrib["saveCount"], 0, False)
|
||
if "autoCount" in xRoot.attrib:
|
||
self.autoCount = checkInt(xRoot.attrib["autoCount"], 0, False)
|
||
|
||
logger.verbose("XML root is %s" % nwxRoot)
|
||
logger.verbose("File version is %s" % fileVersion)
|
||
|
||
if not nwxRoot == "novelWriterXML" or not fileVersion == "1.0":
|
||
self.makeAlert(
|
||
"Project file does not appear to be a novelWriterXML file version 1.0",
|
||
nwAlert.ERROR
|
||
)
|
||
return False
|
||
|
||
if int(hexVersion, 16) > int(nw.__hexversion__, 16) and self.mainConf.showGUI:
|
||
msgBox = QMessageBox()
|
||
msgRes = msgBox.question(self.theParent, "Version Conflict", (
|
||
"This project was saved by a newer version of %s, version %s. This is version %s. "
|
||
"If you continue to open the project, some attributes and settings may not be "
|
||
"preserved. Continue opening the project?"
|
||
) % (
|
||
nw.__package__, appVersion, nw.__version__
|
||
))
|
||
if msgRes != QMessageBox.Yes:
|
||
return False
|
||
|
||
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":
|
||
logger.verbose("Working Title: '%s'" % xItem.text)
|
||
self.projName = xItem.text
|
||
elif xItem.tag == "title":
|
||
logger.verbose("Title is '%s'" % xItem.text)
|
||
self.bookTitle = xItem.text
|
||
elif xItem.tag == "author":
|
||
logger.verbose("Author: '%s'" % xItem.text)
|
||
self.bookAuthors.append(xItem.text)
|
||
elif xItem.tag == "backup":
|
||
self.doBackup = checkBool(xItem.text, False)
|
||
elif xChild.tag == "settings":
|
||
logger.debug("Found project settings")
|
||
for xItem in xChild:
|
||
if xItem.text is None:
|
||
continue
|
||
if xItem.tag == "spellCheck":
|
||
self.spellCheck = checkBool(xItem.text, False)
|
||
elif xItem.tag == "autoOutline":
|
||
self.autoOutline = checkBool(xItem.text, True)
|
||
elif xItem.tag == "lastEdited":
|
||
self.lastEdited = checkString(xItem.text, None, True)
|
||
elif xItem.tag == "lastViewed":
|
||
self.lastViewed = checkString(xItem.text, None, True)
|
||
elif xItem.tag == "lastWordCount":
|
||
self.lastWCount = checkInt(xItem.text, 0, False)
|
||
elif xItem.tag == "status":
|
||
self.statusItems.unpackEntries(xItem)
|
||
elif xItem.tag == "importance":
|
||
self.importItems.unpackEntries(xItem)
|
||
elif xItem.tag == "autoReplace":
|
||
for xEntry in xItem:
|
||
self.autoReplace[xEntry.tag] = checkString(xEntry.text, None, False)
|
||
elif xItem.tag == "titleFormat":
|
||
titleFormat = self.titleFormat.copy()
|
||
for xEntry in xItem:
|
||
titleFormat[xEntry.tag] = checkString(xEntry.text, "", False)
|
||
self.setTitleFormat(titleFormat)
|
||
elif xChild.tag == "content":
|
||
logger.debug("Found project content")
|
||
self.projTree.unpackXML(xChild)
|
||
|
||
self.optState.loadSettings()
|
||
|
||
# Update recent projects
|
||
self.mainConf.updateRecentCache(self.projPath, self.projName, self.lastWCount, time())
|
||
self.mainConf.saveRecentCache()
|
||
|
||
self.theParent.setStatus("Opened Project: %s" % self.projName)
|
||
|
||
self._scanProjectFolder()
|
||
self.setProjectChanged(False)
|
||
self.projOpened = time()
|
||
self.projAltered = False
|
||
self._writeLockFile()
|
||
|
||
return True
|
||
|
||
def saveProject(self, autoSave=False):
|
||
"""Save the project main XML file. The saving command itself
|
||
uses a temporary filename, and the file is renamed afterwards to
|
||
make sure if the save fails, we're not left with a truncated
|
||
file.
|
||
"""
|
||
|
||
if self.projPath is None:
|
||
self.makeAlert("Project path not set, cannot save.", nwAlert.ERROR)
|
||
return False
|
||
|
||
self.projMeta = path.join(self.projPath,"meta")
|
||
saveTime = time()
|
||
|
||
if not self._checkFolder(self.projPath):
|
||
return False
|
||
if not self._checkFolder(self.projMeta):
|
||
return False
|
||
|
||
logger.debug("Saving project: %s" % self.projPath)
|
||
|
||
if autoSave:
|
||
self.autoCount += 1
|
||
else:
|
||
self.saveCount += 1
|
||
|
||
# Root element and project details
|
||
logger.debug("Writing project meta")
|
||
nwXML = etree.Element("novelWriterXML",attrib={
|
||
"appVersion" : str(nw.__version__),
|
||
"hexVersion" : str(nw.__hexversion__),
|
||
"fileVersion" : "1.0",
|
||
"saveCount" : str(self.saveCount),
|
||
"autoCount" : str(self.autoCount),
|
||
"timeStamp" : formatTimeStamp(saveTime),
|
||
})
|
||
|
||
# Save Project Meta
|
||
xProject = etree.SubElement(nwXML, "project")
|
||
self._packProjectValue(xProject, "name", self.projName, True)
|
||
self._packProjectValue(xProject, "title", self.bookTitle, True)
|
||
self._packProjectValue(xProject, "author", self.bookAuthors)
|
||
self._packProjectValue(xProject, "backup", self.doBackup)
|
||
|
||
# Save Project Settings
|
||
xSettings = etree.SubElement(nwXML, "settings")
|
||
self._packProjectValue(xSettings, "spellCheck", self.spellCheck)
|
||
self._packProjectValue(xSettings, "autoOutline", self.autoOutline)
|
||
self._packProjectValue(xSettings, "lastEdited", self.lastEdited)
|
||
self._packProjectValue(xSettings, "lastViewed", self.lastViewed)
|
||
self._packProjectValue(xSettings, "lastWordCount", self.currWCount)
|
||
|
||
xAutoRep = etree.SubElement(xSettings, "autoReplace")
|
||
for aKey, aValue in self.autoReplace.items():
|
||
if len(aKey) > 0:
|
||
self._packProjectValue(xAutoRep, aKey, aValue)
|
||
|
||
xTitleFmt = etree.SubElement(xSettings, "titleFormat")
|
||
for aKey, aValue in self.titleFormat.items():
|
||
if len(aKey) > 0:
|
||
self._packProjectValue(xTitleFmt, aKey, aValue)
|
||
|
||
xStatus = etree.SubElement(xSettings,"status")
|
||
self.statusItems.packEntries(xStatus)
|
||
xStatus = etree.SubElement(xSettings,"importance")
|
||
self.importItems.packEntries(xStatus)
|
||
|
||
# Save Tree Content
|
||
logger.debug("Writing project content")
|
||
self.projTree.packXML(nwXML)
|
||
|
||
# Write the xml tree to file
|
||
tempFile = path.join(self.projPath, self.projFile+"~")
|
||
saveFile = path.join(self.projPath, self.projFile)
|
||
backFile = path.join(self.projPath, self.projFile[:-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 e:
|
||
self.makeAlert(["Failed to save project.",str(e)], nwAlert.ERROR)
|
||
return False
|
||
|
||
# If we're here, the file was successfully saved,
|
||
# so let's sort out the temps and backups
|
||
if path.isfile(backFile):
|
||
unlink(backFile)
|
||
if path.isfile(saveFile):
|
||
rename(saveFile, backFile)
|
||
rename(tempFile, saveFile)
|
||
|
||
# Save project GUI options
|
||
self.optState.saveSettings()
|
||
|
||
# Update recent projects
|
||
self.mainConf.updateRecentCache(self.projPath, self.projName, self.currWCount, saveTime)
|
||
self.mainConf.saveRecentCache()
|
||
|
||
self._writeLockFile()
|
||
self.theParent.setStatus("Saved Project: %s" % self.projName)
|
||
self.setProjectChanged(False)
|
||
|
||
return True
|
||
|
||
def closeProject(self):
|
||
"""Close the current project and clear all meta data.
|
||
"""
|
||
self._appendSessionStats()
|
||
self._clearLockFile()
|
||
self.clearProject()
|
||
self.lockedBy = None
|
||
return True
|
||
|
||
##
|
||
# Backup Project
|
||
##
|
||
|
||
def zipIt(self, doNotify):
|
||
"""Create a zip file of the entire project.
|
||
"""
|
||
|
||
logger.info("Backing up project")
|
||
self.theParent.statusBar.setStatus("Backing up project ...")
|
||
|
||
if self.mainConf.backupPath is None or self.mainConf.backupPath == "":
|
||
self.theParent.makeAlert((
|
||
"Cannot backup project because no backup path is set. "
|
||
"Please set a valid backup location in Tools > Preferences."
|
||
), nwAlert.WARN)
|
||
return False
|
||
|
||
if self.projName is None or self.projName == "":
|
||
self.theParent.makeAlert((
|
||
"Cannot backup project because no project name is set. "
|
||
"Please set a Working Title in Project > Project Settings."
|
||
), nwAlert.WARN)
|
||
return False
|
||
|
||
if not path.isdir(self.mainConf.backupPath):
|
||
self.theParent.makeAlert((
|
||
"Cannot backup project because the backup path does not exist. "
|
||
"Please set a valid backup location in Tools > Preferences."
|
||
), nwAlert.WARN)
|
||
return False
|
||
|
||
cleanName = self.getFileSafeProjectName()
|
||
baseDir = path.join(self.mainConf.backupPath, cleanName)
|
||
if not path.isdir(baseDir):
|
||
try:
|
||
mkdir(baseDir)
|
||
logger.debug("Created folder %s" % baseDir)
|
||
except Exception as e:
|
||
self.theParent.makeAlert(
|
||
["Could not create backup folder.",str(e)],
|
||
nwAlert.ERROR
|
||
)
|
||
return False
|
||
|
||
archName = "Backup from %s" % formatTimeStamp(time(), fileSafe=True)
|
||
baseName = path.join(baseDir, archName)
|
||
|
||
try:
|
||
self._clearLockFile()
|
||
make_archive(baseName, "zip", self.projPath, ".")
|
||
self._writeLockFile()
|
||
if doNotify:
|
||
self.theParent.makeAlert(
|
||
"Backup archive file written to: '%s.zip'" % path.join(cleanName, archName),
|
||
nwAlert.INFO
|
||
)
|
||
else:
|
||
logger.info("Backup written to: %s" % archName)
|
||
except Exception as e:
|
||
self.theParent.makeAlert(
|
||
["Could not write backup archive.",str(e)],
|
||
nwAlert.ERROR
|
||
)
|
||
return False
|
||
|
||
self.theParent.statusBar.setStatus("Project backed up to '%s.zip'" % baseName)
|
||
|
||
return True
|
||
|
||
##
|
||
# Setters
|
||
##
|
||
|
||
def setProjectPath(self, projPath):
|
||
"""Set the project storage path, and also expand ~ to the user
|
||
directory using the path library.
|
||
"""
|
||
if projPath is None or projPath == "":
|
||
self.projPath = None
|
||
else:
|
||
if projPath.startswith("~"):
|
||
projPath = path.expanduser(projPath)
|
||
self.projPath = projPath
|
||
self.setProjectChanged(True)
|
||
return True
|
||
|
||
def setProjectName(self, projName):
|
||
"""Set the project name (working title), This is the the title
|
||
used for backup files etc.
|
||
"""
|
||
self.projName = projName.strip()
|
||
self.setProjectChanged(True)
|
||
return True
|
||
|
||
def setBookTitle(self, bookTitle):
|
||
"""Set the boom title, that is, the title to include in exports.
|
||
"""
|
||
self.bookTitle = bookTitle.strip()
|
||
self.setProjectChanged(True)
|
||
return True
|
||
|
||
def setBookAuthors(self, bookAuthors):
|
||
"""A line separated list of book authors, parsed into an array.
|
||
"""
|
||
self.bookAuthors = []
|
||
for bookAuthor in bookAuthors.split("\n"):
|
||
bookAuthor = bookAuthor.strip()
|
||
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 notified in case dependant settings are missing.
|
||
"""
|
||
self.doBackup = doBackup
|
||
if doBackup:
|
||
if not path.isdir(self.mainConf.backupPath):
|
||
self.theParent.makeAlert((
|
||
"You must set a valid backup path in preferences to use "
|
||
"the automatic project backup feature."
|
||
), nwAlert.WARN)
|
||
if self.projName == "":
|
||
self.theParent.makeAlert((
|
||
"You must set a valid project name in project settings to "
|
||
"use the automatic project backup feature."
|
||
), nwAlert.WARN)
|
||
return True
|
||
|
||
def setSpellCheck(self, theMode):
|
||
"""Enable/disable spell checking.
|
||
"""
|
||
if self.spellCheck != theMode:
|
||
self.spellCheck = theMode
|
||
self.setProjectChanged(True)
|
||
return True
|
||
|
||
def setAutoOutline(self, theMode):
|
||
"""Enable/disable automatic update of project outline.
|
||
"""
|
||
if self.autoOutline != theMode:
|
||
self.autoOutline = theMode
|
||
self.setProjectChanged(True)
|
||
return True
|
||
|
||
def setTreeOrder(self, newOrder):
|
||
"""A list representing the liner/flattened order of project
|
||
items in the GUI project tree. The user can rearrange the order
|
||
by drag-and-drop. Forwarded to the NWTree class.
|
||
"""
|
||
if len(self.projTree) != len(newOrder):
|
||
logger.warning("Size of new and old tree order does not match")
|
||
self.projTree.setOrder(newOrder)
|
||
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 setProjectWordCount(self, theCount):
|
||
"""Set the current project word count.
|
||
"""
|
||
if self.currWCount != theCount:
|
||
self.currWCount = theCount
|
||
self.setProjectChanged(True)
|
||
return True
|
||
|
||
def setStatusColours(self, newCols):
|
||
"""Update the list of novel file status flags. Also iterate
|
||
through the project and replace keys that have been renamed.
|
||
"""
|
||
replaceMap = self.statusItems.setNewEntries(newCols)
|
||
for nwItem in self.projTree:
|
||
if nwItem.itemClass == nwItemClass.NOVEL:
|
||
if nwItem.itemStatus in replaceMap.keys():
|
||
nwItem.setStatus(replaceMap[nwItem.itemStatus])
|
||
self.setProjectChanged(True)
|
||
return
|
||
|
||
def setImportColours(self, newCols):
|
||
"""Update the list of note file importance flags. Also iterate
|
||
through the project and replace keys that have been renamed.
|
||
"""
|
||
replaceMap = self.importItems.setNewEntries(newCols)
|
||
for nwItem in self.projTree:
|
||
if nwItem.itemClass != nwItemClass.NOVEL:
|
||
if nwItem.itemStatus in replaceMap.keys():
|
||
nwItem.setStatus(replaceMap[nwItem.itemStatus])
|
||
self.setProjectChanged(True)
|
||
return
|
||
|
||
def setAutoReplace(self, autoReplace):
|
||
"""Update the auto-replace dictionary. This replaces the entire
|
||
dictionary, so alterations have to be made in a copy.
|
||
"""
|
||
self.autoReplace = autoReplace
|
||
return
|
||
|
||
def setTitleFormat(self, titleFormat):
|
||
"""Set the formatting of titles in the project.
|
||
"""
|
||
for valKey, valEntry in titleFormat.items():
|
||
if valKey in ("title","chapter","unnumbered","scene","section"):
|
||
self.titleFormat[valKey] = checkString(valEntry, self.titleFormat[valKey], False)
|
||
elif valKey in ("withSynopsis","withComments","withKeywords"):
|
||
self.titleFormat[valKey] = checkBool(valEntry, False, False)
|
||
return
|
||
|
||
def setProjectChanged(self, bValue):
|
||
"""Toggle the project changed flag, and propagate the
|
||
information to the GUI statusbar.
|
||
"""
|
||
self.projChanged = bValue
|
||
self.theParent.setProjectStatus(self.projChanged)
|
||
if bValue:
|
||
# If we've changed the project at all, this should be True
|
||
self.projAltered = True
|
||
return self.projChanged
|
||
|
||
##
|
||
# Getters
|
||
##
|
||
|
||
def getFileSafeProjectName(self):
|
||
"""Returns a filename safe version of the project name.
|
||
"""
|
||
cleanName = ""
|
||
for c in self.projName.strip():
|
||
if c.isalpha() or c.isdigit() or c == " ":
|
||
cleanName += c
|
||
return cleanName
|
||
|
||
def getSessionWordCount(self):
|
||
"""Returns the number of words added or removed this session.
|
||
"""
|
||
return self.currWCount - self.lastWCount
|
||
|
||
def getProjectItems(self):
|
||
"""This function ensures that the item tree loaded is sent to
|
||
the GUI tree view in such a way that the tree can be built. That
|
||
is, the parent item must be sent before its child. In principle,
|
||
a proper XML file will already ensure that, but in the event the
|
||
order has been altered, or a file is orphaned, this function is
|
||
capable of handling it.
|
||
"""
|
||
sentItems = []
|
||
iterItems = self.projTree.handles()
|
||
n = 0
|
||
nMax = len(iterItems)
|
||
while n < nMax:
|
||
tHandle = iterItems[n]
|
||
tItem = self.projTree[tHandle]
|
||
n += 1
|
||
if n > 10000:
|
||
return # Just in case
|
||
if tItem is None:
|
||
# Technically a bug since treeOrder is built from the
|
||
# same data as projTree
|
||
continue
|
||
elif tItem.parHandle is None:
|
||
# Item is a root, or already been identified as an
|
||
# orphaned item
|
||
sentItems.append(tHandle)
|
||
yield tItem
|
||
elif tItem.parHandle in sentItems:
|
||
# Item's parent has been sent, so all is fine
|
||
sentItems.append(tHandle)
|
||
yield tItem
|
||
elif tItem.parHandle in iterItems:
|
||
# Item's parent exists, but hasn't been sent yet, so add
|
||
# it again to the end
|
||
logger.warning("Item %s found before its parent" % tHandle)
|
||
iterItems.append(tHandle)
|
||
nMax = len(iterItems)
|
||
else:
|
||
# Item is orphaned
|
||
logger.error("Item %s has no parent in current tree" % tHandle)
|
||
tItem.setParent(None)
|
||
yield tItem
|
||
|
||
##
|
||
# Class Methods
|
||
##
|
||
|
||
def countStatus(self):
|
||
"""Count how many times the various status flags are used in the
|
||
project tree. The counts themselves are kept in the NWStatus
|
||
objects. This is essentially a refresh.
|
||
"""
|
||
self.statusItems.resetCounts()
|
||
self.importItems.resetCounts()
|
||
for nwItem in self.projTree:
|
||
if nwItem.itemClass == nwItemClass.NOVEL:
|
||
self.statusItems.countEntry(nwItem.itemStatus)
|
||
else:
|
||
self.importItems.countEntry(nwItem.itemStatus)
|
||
return
|
||
|
||
##
|
||
# Internal Functions
|
||
##
|
||
|
||
def _readLockFile(self):
|
||
"""Reads the lock file in the project folder.
|
||
"""
|
||
|
||
if self.projPath is None:
|
||
return ["ERROR"]
|
||
|
||
lockFile = path.join(self.projPath, nwFiles.PROJ_LOCK)
|
||
if not path.isfile(lockFile):
|
||
return []
|
||
|
||
try:
|
||
with open(lockFile, mode="r", encoding="utf8") as inFile:
|
||
theData = inFile.read()
|
||
theLines = theData.splitlines()
|
||
if len(theLines) == 4:
|
||
return theLines
|
||
else:
|
||
return ["ERROR"]
|
||
|
||
except Exception as e:
|
||
logger.error("Failed to read project lockfile")
|
||
logger.error(str(e))
|
||
return ["ERROR"]
|
||
|
||
return ["ERROR"]
|
||
|
||
def _writeLockFile(self):
|
||
"""Writes a lock file to the project folder.
|
||
"""
|
||
|
||
if self.projPath is None:
|
||
return False
|
||
|
||
lockFile = path.join(self.projPath, nwFiles.PROJ_LOCK)
|
||
try:
|
||
with open(lockFile, mode="w+", encoding="utf8") as outFile:
|
||
outFile.write("%s\n" % self.mainConf.hostName)
|
||
outFile.write("%s\n" % self.mainConf.osType)
|
||
outFile.write("%s\n" % self.mainConf.kernelVer)
|
||
outFile.write("%d\n" % time())
|
||
|
||
except Exception as e:
|
||
logger.error("Failed to write project lockfile")
|
||
logger.error(str(e))
|
||
return False
|
||
|
||
return True
|
||
|
||
def _clearLockFile(self):
|
||
"""Remove the lock file, if it exists.
|
||
"""
|
||
if self.projPath is None:
|
||
return False
|
||
|
||
lockFile = path.join(self.projPath, nwFiles.PROJ_LOCK)
|
||
if path.isfile(lockFile):
|
||
try:
|
||
unlink(lockFile)
|
||
return True
|
||
except Exception as e:
|
||
logger.error("Failed to remove project lockfile")
|
||
logger.error(str(e))
|
||
return False
|
||
|
||
return None
|
||
|
||
def _checkFolder(self, thePath):
|
||
if not path.isdir(thePath):
|
||
try:
|
||
mkdir(thePath)
|
||
logger.debug("Created folder %s" % thePath)
|
||
except Exception as e:
|
||
self.makeAlert(["Could not create folder.",str(e)], nwAlert.ERROR)
|
||
return False
|
||
return True
|
||
|
||
def _packProjectValue(self, xParent, theName, theValue, allowNone=True):
|
||
if not isinstance(theValue, list):
|
||
theValue = [theValue]
|
||
for aValue in theValue:
|
||
if not isinstance(aValue, str):
|
||
aValue = str(aValue)
|
||
if aValue == "" and not allowNone: continue
|
||
xItem = etree.SubElement(xParent,theName)
|
||
xItem.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
|
||
orphaned files so the user can either delete them, or put them
|
||
back into the project tree.
|
||
"""
|
||
|
||
if self.projPath is None:
|
||
return
|
||
|
||
# First, scan the project data folders
|
||
itemList = []
|
||
for subItem in listdir(self.projPath):
|
||
if subItem[:5] != "data_":
|
||
continue
|
||
dataDir = path.join(self.projPath,subItem)
|
||
for subFile in listdir(dataDir):
|
||
if subFile[-4:] == ".nwd":
|
||
newItem = path.join(subItem,subFile)
|
||
itemList.append(newItem)
|
||
|
||
# Then check the valid files
|
||
orphanFiles = []
|
||
for fileItem in itemList:
|
||
if len(fileItem) != 28:
|
||
# Just to be safe, shouldn't happen
|
||
logger.warning("Skipping file %s" % fileItem)
|
||
continue
|
||
fHandle = fileItem[5]+fileItem[7:19]
|
||
if fHandle in self.projTree:
|
||
logger.debug("Checking file %s, handle %s: OK" % (fileItem,fHandle))
|
||
else:
|
||
logger.debug("Checking file %s, handle %s: Orphaned" % (fileItem,fHandle))
|
||
orphanFiles.append(fHandle)
|
||
|
||
# Report status
|
||
if len(orphanFiles) > 0:
|
||
self.makeAlert(
|
||
"Found %d orphaned file(s) in project folder!" % len(orphanFiles),
|
||
nwAlert.WARN
|
||
)
|
||
else:
|
||
logger.debug("File check OK")
|
||
return
|
||
|
||
# Handle orphans
|
||
aDoc = NWDoc(self, self.theParent)
|
||
nOrph = 0
|
||
for oHandle in orphanFiles:
|
||
|
||
# Look for meta data
|
||
oName = ""
|
||
if aDoc.openDocument(oHandle, showStatus=False, isOrphan=True):
|
||
oName, oPath = aDoc.getMeta()
|
||
|
||
if oName == "":
|
||
nOrph += 1
|
||
oName = "Orphaned File %d" % nOrph
|
||
|
||
orphItem = NWItem(self)
|
||
orphItem.setName(oName)
|
||
orphItem.setType(nwItemType.FILE)
|
||
orphItem.setClass(nwItemClass.NO_CLASS)
|
||
orphItem.setLayout(nwItemLayout.NO_LAYOUT)
|
||
self.projTree.append(oHandle, None, orphItem)
|
||
|
||
return
|
||
|
||
def _appendSessionStats(self):
|
||
"""Append session statistics to the sessions log file.
|
||
"""
|
||
|
||
if self.projMeta is None:
|
||
return False
|
||
|
||
sessionFile = path.join(self.projMeta, nwFiles.SESS_INFO)
|
||
|
||
with open(sessionFile, mode="a+", encoding="utf8") as outFile:
|
||
print((
|
||
"Start: {opened:s} "
|
||
"End: {closed:s} "
|
||
"Words: {words:8d}"
|
||
).format(
|
||
opened = formatTimeStamp(self.projOpened),
|
||
closed = formatTimeStamp(time()),
|
||
words = self.getSessionWordCount(),
|
||
), file=outFile)
|
||
|
||
return True
|
||
|
||
# END Class NWProject
|
||
|
||
# ================================================================================================ #
|
||
# NWTree
|
||
# Class holding the project tree for the NWProject
|
||
# ================================================================================================ #
|
||
|
||
class NWTree():
|
||
|
||
def __init__(self, theProject):
|
||
|
||
self.theProject = theProject
|
||
|
||
self._projTree = {} # Holds all the items of the project
|
||
self._treeOrder = [] # The order of the tree items on the tree view
|
||
self._treeRoots = [] # The root items of the tree
|
||
self._trashRoot = None # The handle of the trash root folder
|
||
self._theLength = 0 # Always the length of _treeOrder
|
||
self._theIndex = 0 # The current iterator index
|
||
self._treeChanged = False # True if tree structure has changed
|
||
self._handleSeed = None # Used for generating handles for testing
|
||
|
||
return
|
||
|
||
##
|
||
# Class Methods
|
||
##
|
||
|
||
def clear(self):
|
||
"""Clear the item tree entirely.
|
||
"""
|
||
self._projTree = {}
|
||
self._treeOrder = []
|
||
self._treeRoots = []
|
||
self._trashRoot = None
|
||
self._theLength = 0
|
||
self._theIndex = 0
|
||
self._treeChanged = False
|
||
return
|
||
|
||
def handles(self):
|
||
"""Returns a copy of the list of all the active handles.
|
||
"""
|
||
return self._treeOrder.copy()
|
||
|
||
def append(self, tHandle, pHandle, nwItem):
|
||
"""Add a new item to the end of the tree.
|
||
"""
|
||
tHandle = checkString(tHandle, None, True)
|
||
pHandle = checkString(pHandle, None, True)
|
||
if tHandle is None:
|
||
tHandle = self._makeHandle()
|
||
|
||
logger.verbose("Adding entry %s with parent %s" % (str(tHandle), str(pHandle)))
|
||
|
||
nwItem.setHandle(tHandle)
|
||
nwItem.setParent(pHandle)
|
||
|
||
self._projTree[tHandle] = nwItem
|
||
self._treeOrder.append(tHandle)
|
||
|
||
if nwItem.itemType == nwItemType.ROOT:
|
||
logger.verbose("Entry %s is a root item" % str(tHandle))
|
||
self._treeRoots.append(tHandle)
|
||
|
||
if nwItem.itemType == nwItemType.TRASH:
|
||
if self._trashRoot is None:
|
||
logger.verbose("Entry %s is the trash folder" % str(tHandle))
|
||
self._trashRoot = tHandle
|
||
else:
|
||
logger.error("Only one trash folder allowed")
|
||
|
||
self._theLength = len(self._treeOrder)
|
||
self._setTreeChanged(True)
|
||
|
||
return
|
||
|
||
def packXML(self, xParent):
|
||
"""Pack the content of the tree into an XML object.
|
||
"""
|
||
xContent = etree.SubElement(xParent, "content", attrib={
|
||
"count":str(self._theLength)}
|
||
)
|
||
for tHandle in self._treeOrder:
|
||
tItem = self.__getitem__(tHandle)
|
||
tItem.packXML(xContent)
|
||
return
|
||
|
||
def unpackXML(self, xContent):
|
||
"""Iterate through all items of a content XML object 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:
|
||
nwItem = NWItem(self.theProject)
|
||
if nwItem.unpackXML(xItem):
|
||
self.append(nwItem.itemHandle, nwItem.parHandle, nwItem)
|
||
|
||
return True
|
||
|
||
##
|
||
# Tree Structure Methods
|
||
##
|
||
|
||
def trashRoot(self):
|
||
"""Returns the handle of the trash folder, or None if there
|
||
isn't one.
|
||
"""
|
||
if self._trashRoot:
|
||
return self._trashRoot
|
||
return None
|
||
|
||
def findRoot(self, theClass):
|
||
"""Find the root item for a given class.
|
||
Note: This returns the first item for class CUSTOM.
|
||
"""
|
||
for aRoot in self._treeRoots:
|
||
tItem = self.__getitem__(aRoot)
|
||
if tItem is None:
|
||
continue
|
||
if theClass == tItem.itemClass:
|
||
return tItem.itemHandle
|
||
return None
|
||
|
||
def checkRootUnique(self, theClass):
|
||
"""Checks if there already is a root entry of class 'theClass'
|
||
in the root of the project tree. CUSTOM class is skipped as it
|
||
is not required to be unique.
|
||
"""
|
||
if theClass == nwItemClass.CUSTOM:
|
||
return True
|
||
for aRoot in self._treeRoots:
|
||
tItem = self.__getitem__(aRoot)
|
||
if theClass == tItem.itemClass:
|
||
return False
|
||
return True
|
||
|
||
def getRootItem(self, tHandle):
|
||
"""Iterate upwards in the tree until we find the item with
|
||
parent None, the root item. We do this with a for loop with a
|
||
maximum depth of 200 to make infinite loops impossible.
|
||
"""
|
||
tItem = self.__getitem__(tHandle)
|
||
if tItem is not None:
|
||
for i in range(200):
|
||
if tItem.parHandle is None:
|
||
return tHandle
|
||
else:
|
||
tHandle = tItem.parHandle
|
||
tItem = self.__getitem__(tHandle)
|
||
if tItem is None:
|
||
return tHandle
|
||
return None
|
||
|
||
def getItemPath(self, tHandle):
|
||
"""Iterate upwards in the tree until we find the item with
|
||
parent None, the root item, and return the list of handles.
|
||
We do this with a for loop with a maximum depth of 200 to make
|
||
infinite loops impossible.
|
||
"""
|
||
tTree = []
|
||
tItem = self.__getitem__(tHandle)
|
||
if tItem is not None:
|
||
tTree.append(tHandle)
|
||
for i in range(200):
|
||
if tItem.parHandle is None:
|
||
return tTree
|
||
else:
|
||
tHandle = tItem.parHandle
|
||
tItem = self.__getitem__(tHandle)
|
||
if tItem is None:
|
||
return tTree
|
||
else:
|
||
tTree.append(tHandle)
|
||
return tTree
|
||
|
||
##
|
||
# Setters
|
||
##
|
||
|
||
def setOrder(self, newOrder):
|
||
"""Reorders the tree based on a list of items.
|
||
"""
|
||
tmpOrder = []
|
||
|
||
# Add all known elements to a new temp list
|
||
for tHandle in newOrder:
|
||
if tHandle in self._projTree:
|
||
tmpOrder.append(tHandle)
|
||
else:
|
||
logger.error("Handle %s in new tree order is not in project tree" % tHandle)
|
||
|
||
# Do a reverse lookup to check for items that will be lost
|
||
# This is mainly for debugging purposes
|
||
for tHandle in self._treeOrder:
|
||
if tHandle not in tmpOrder:
|
||
logger.warning("Handle %s in old tree order is not in new tree order" % tHandle)
|
||
|
||
# Save the temp list
|
||
self._treeOrder = tmpOrder
|
||
self._theLength = len(self._treeOrder)
|
||
self._setTreeChanged(True)
|
||
|
||
return
|
||
|
||
def setSeed(self, theSeed):
|
||
"""Used for debugging!
|
||
Sets a seed for generating handles so that they always come out
|
||
in a predictable order.
|
||
"""
|
||
self._handleSeed = theSeed
|
||
return
|
||
|
||
##
|
||
# Meta Methods
|
||
##
|
||
|
||
def __len__(self):
|
||
return self._theLength
|
||
|
||
def __bool__(self):
|
||
return self._theLength > 0
|
||
|
||
##
|
||
# Item Access Methods
|
||
##
|
||
|
||
def __getitem__(self, tHandle):
|
||
"""Return a project item based on its handle. Returns None if
|
||
the handle doesn't exist in the project.
|
||
"""
|
||
if tHandle in self._projTree:
|
||
return self._projTree[tHandle]
|
||
logger.error("No tree item with handle %s" % str(tHandle))
|
||
return None
|
||
|
||
def __delitem__(self, tHandle):
|
||
"""This only removes the item from the order list, but not from
|
||
the project tree.
|
||
"""
|
||
if tHandle not in self._treeOrder:
|
||
logger.warning(
|
||
"Could not remove item %s from project tree as it does not exist" % tHandle
|
||
)
|
||
return False
|
||
self._treeOrder.remove(tHandle)
|
||
self._theLength = len(self._treeOrder)
|
||
self._setTreeChanged(True)
|
||
return True
|
||
|
||
def __contains__(self, tHandle):
|
||
"""Checks if a handle exists in the tree.
|
||
"""
|
||
return tHandle in self._treeOrder
|
||
|
||
##
|
||
# Iterator Methods
|
||
##
|
||
|
||
def __iter__(self):
|
||
"""Initiates the iterator.
|
||
"""
|
||
self._theIndex = 0
|
||
return self
|
||
|
||
def __next__(self):
|
||
"""Returns the item from the next entry in the _treeOrder list.
|
||
"""
|
||
if self._theIndex < self._theLength:
|
||
theItem = self.__getitem__(self._treeOrder[self._theIndex])
|
||
self._theIndex += 1
|
||
return theItem
|
||
else:
|
||
raise StopIteration
|
||
|
||
##
|
||
# Internal Functions
|
||
##
|
||
|
||
def _setTreeChanged(self, theState):
|
||
"""Set the changed flag to theState, and if being set to True,
|
||
propagate that state change to the parent NWProject class.
|
||
"""
|
||
self._treeChanged = theState
|
||
if theState:
|
||
self.theProject.setProjectChanged(True)
|
||
return
|
||
|
||
def _makeHandle(self, addSeed=""):
|
||
"""Generate a unique item handle. In the unlikely event that the
|
||
key already exists, salt the seed and generate a new handle.
|
||
"""
|
||
if self._handleSeed is None:
|
||
newSeed = str(time()) + addSeed
|
||
else:
|
||
# This is used for debugging
|
||
newSeed = str(self._handleSeed)
|
||
self._handleSeed += 1
|
||
logger.verbose("Generating handle with seed '%s'" % newSeed)
|
||
itemHandle = sha256(newSeed.encode()).hexdigest()[0:13]
|
||
if itemHandle in self._projTree:
|
||
logger.warning("Duplicate handle encountered! Retrying ...")
|
||
itemHandle = self._makeHandle(addSeed+"!")
|
||
return itemHandle
|
||
|
||
# END Class NWTree
|
||
|
||
# ================================================================================================ #
|
||
# NWItem
|
||
# Class holding the project items making up the NWProject
|
||
# ================================================================================================ #
|
||
|
||
class NWItem():
|
||
|
||
def __init__(self, theProject):
|
||
|
||
self.theProject = theProject
|
||
|
||
self.itemName = ""
|
||
self.itemHandle = None
|
||
self.parHandle = None
|
||
self.itemOrder = None
|
||
self.itemType = nwItemType.NO_TYPE
|
||
self.itemClass = nwItemClass.NO_CLASS
|
||
self.itemLayout = nwItemLayout.NO_LAYOUT
|
||
self.itemStatus = None
|
||
self.isExpanded = False
|
||
self.isExported = True
|
||
|
||
# Document Meta Data
|
||
self.charCount = 0
|
||
self.wordCount = 0
|
||
self.paraCount = 0
|
||
self.cursorPos = 0
|
||
|
||
return
|
||
|
||
##
|
||
# XML Pack/Unpack
|
||
##
|
||
|
||
def packXML(self, xParent):
|
||
"""Packs all the data in the class instance into an XML object.
|
||
"""
|
||
xPack = etree.SubElement(xParent,"item",attrib={
|
||
"handle" : str(self.itemHandle),
|
||
"order" : str(self.itemOrder),
|
||
"parent" : str(self.parHandle),
|
||
})
|
||
xSub = self._subPack(xPack,"name", text=str(self.itemName))
|
||
xSub = self._subPack(xPack,"type", text=str(self.itemType.name))
|
||
xSub = self._subPack(xPack,"class", text=str(self.itemClass.name))
|
||
xSub = self._subPack(xPack,"status", text=str(self.itemStatus))
|
||
xSub = self._subPack(xPack,"expanded", text=str(self.isExpanded))
|
||
if self.itemType == nwItemType.FILE:
|
||
xSub = self._subPack(xPack,"exported", text=str(self.isExported))
|
||
xSub = self._subPack(xPack,"layout", text=str(self.itemLayout.name))
|
||
xSub = self._subPack(xPack,"charCount", text=str(self.charCount), none=False)
|
||
xSub = self._subPack(xPack,"wordCount", text=str(self.wordCount), none=False)
|
||
xSub = self._subPack(xPack,"paraCount", text=str(self.paraCount), none=False)
|
||
xSub = self._subPack(xPack,"cursorPos", text=str(self.cursorPos), none=False)
|
||
return
|
||
|
||
def unpackXML(self, xItem):
|
||
"""Sets the values from an XML entry of type 'item'.
|
||
"""
|
||
|
||
if xItem.tag != "item":
|
||
logger.error("XML entry is not an NWItem")
|
||
return False
|
||
|
||
if "handle" in xItem.attrib:
|
||
self.itemHandle = xItem.attrib["handle"]
|
||
else:
|
||
logger.error("XML item entry does not have a handle")
|
||
return False
|
||
|
||
if "parent" in xItem.attrib:
|
||
self.parHandle = xItem.attrib["parent"]
|
||
|
||
setMap = {
|
||
"name" : self.setName,
|
||
"order" : self.setOrder,
|
||
"type" : self.setType,
|
||
"class" : self.setClass,
|
||
"layout" : self.setLayout,
|
||
"status" : self.setStatus,
|
||
"expanded" : self.setExpanded,
|
||
"exported" : self.setExported,
|
||
"charCount" : self.setCharCount,
|
||
"wordCount" : self.setWordCount,
|
||
"paraCount" : self.setParaCount,
|
||
"cursorPos" : self.setCursorPos,
|
||
}
|
||
for xValue in xItem:
|
||
if xValue.tag in setMap:
|
||
setMap[xValue.tag](xValue.text)
|
||
else:
|
||
logger.error("Unknown tag '%s'" % xValue.tag)
|
||
|
||
return True
|
||
|
||
@staticmethod
|
||
def _subPack(xParent, name, attrib=None, text=None, none=True):
|
||
if not none and (text == None or text == "None"):
|
||
return None
|
||
xSub = etree.SubElement(xParent,name,attrib=attrib)
|
||
if text is not None:
|
||
xSub.text = text
|
||
return xSub
|
||
|
||
##
|
||
# Set Item Values
|
||
##
|
||
|
||
def setName(self, theName):
|
||
self.itemName = theName.strip()
|
||
return
|
||
|
||
def setHandle(self, theHandle):
|
||
if isinstance(theHandle, str):
|
||
if len(theHandle) == 13:
|
||
self.itemHandle = theHandle
|
||
else:
|
||
self.itemHandle = None
|
||
else:
|
||
self.itemHandle = None
|
||
return
|
||
|
||
def setParent(self, theParent):
|
||
if theParent is None:
|
||
self.parHandle = None
|
||
elif isinstance(theParent, str):
|
||
if len(theParent) == 13:
|
||
self.parHandle = theParent
|
||
else:
|
||
self.parHandle = None
|
||
else:
|
||
self.parHandle = None
|
||
return
|
||
|
||
def setOrder(self, theOrder):
|
||
self.itemOrder = checkInt(theOrder, 0)
|
||
return
|
||
|
||
def setType(self, theType):
|
||
if isinstance(theType, nwItemType):
|
||
self.itemType = theType
|
||
elif theType in nwItemType.__members__:
|
||
self.itemType = nwItemType[theType]
|
||
else:
|
||
logger.error("Unrecognised item type '%s'" % theType)
|
||
self.itemType = nwItemType.NO_TYPE
|
||
return
|
||
|
||
def setClass(self, theClass):
|
||
if isinstance(theClass, nwItemClass):
|
||
self.itemClass = theClass
|
||
elif theClass in nwItemClass.__members__:
|
||
self.itemClass = nwItemClass[theClass]
|
||
else:
|
||
logger.error("Unrecognised item class '%s'" % theClass)
|
||
self.itemClass = nwItemClass.NO_CLASS
|
||
return
|
||
|
||
def setLayout(self, theLayout):
|
||
if isinstance(theLayout, nwItemLayout):
|
||
self.itemLayout = theLayout
|
||
elif theLayout in nwItemLayout.__members__:
|
||
self.itemLayout = nwItemLayout[theLayout]
|
||
else:
|
||
logger.error("Unrecognised item layout '%s'" % theLayout)
|
||
self.itemLayout = nwItemLayout.NO_LAYOUT
|
||
return
|
||
|
||
def setStatus(self, theStatus):
|
||
if self.itemClass == nwItemClass.NOVEL:
|
||
self.itemStatus = self.theProject.statusItems.checkEntry(theStatus)
|
||
else:
|
||
self.itemStatus = self.theProject.importItems.checkEntry(theStatus)
|
||
return
|
||
|
||
def setExpanded(self, expState):
|
||
if isinstance(expState, str):
|
||
self.isExpanded = expState == str(True)
|
||
else:
|
||
self.isExpanded = expState == True
|
||
return
|
||
|
||
def setExported(self, expState):
|
||
if isinstance(expState, str):
|
||
self.isExported = expState == str(True)
|
||
else:
|
||
self.isExported = expState == True
|
||
return
|
||
|
||
##
|
||
# Set Document Meta Data
|
||
##
|
||
|
||
def setCharCount(self, theCount):
|
||
self.charCount = checkInt(theCount,0)
|
||
return
|
||
|
||
def setWordCount(self, theCount):
|
||
self.wordCount = checkInt(theCount,0)
|
||
return
|
||
|
||
def setParaCount(self, theCount):
|
||
self.paraCount = checkInt(theCount,0)
|
||
return
|
||
|
||
def setCursorPos(self, thePosition):
|
||
self.cursorPos = checkInt(thePosition,0)
|
||
return
|
||
|
||
# END Class NWItem
|
||
|
||
# ================================================================================================ #
|
||
# NWStatus
|
||
# Class holding the item status values stored in the NWProject
|
||
# ================================================================================================ #
|
||
|
||
class NWStatus():
|
||
|
||
def __init__(self):
|
||
self.theLabels = []
|
||
self.theColours = []
|
||
self.theCounts = []
|
||
self.theMap = {}
|
||
self.theLength = 0
|
||
self.theIndex = 0
|
||
return
|
||
|
||
def addEntry(self, theLabel, theColours):
|
||
theLabel = theLabel.strip()
|
||
if self.lookupEntry(theLabel) is None:
|
||
self.theLabels.append(theLabel)
|
||
self.theColours.append(theColours)
|
||
self.theCounts.append(0)
|
||
self.theMap[theLabel] = self.theLength
|
||
self.theLength += 1
|
||
return True
|
||
|
||
def lookupEntry(self, theLabel):
|
||
if theLabel is None:
|
||
return None
|
||
theLabel = theLabel.strip()
|
||
if theLabel in self.theMap.keys():
|
||
return self.theMap[theLabel]
|
||
return None
|
||
|
||
def checkEntry(self, theStatus):
|
||
if isinstance(theStatus, str):
|
||
theStatus = theStatus.strip()
|
||
if self.lookupEntry(theStatus) is not None:
|
||
return theStatus
|
||
theStatus = checkInt(theStatus, 0, False)
|
||
if theStatus >= 0 and theStatus < self.theLength:
|
||
return self.theLabels[theStatus]
|
||
|
||
def setNewEntries(self, newList):
|
||
|
||
replaceMap = {}
|
||
|
||
if newList is not None:
|
||
|
||
self.theLabels = []
|
||
self.theColours = []
|
||
self.theCounts = []
|
||
self.theMap = {}
|
||
self.theLength = 0
|
||
self.theIndex = 0
|
||
|
||
for nName, nR, nG, nB, oName in newList:
|
||
self.addEntry(nName, (nR, nG, nB))
|
||
if nName != oName and oName is not None:
|
||
replaceMap[oName] = nName
|
||
|
||
return replaceMap
|
||
|
||
def resetCounts(self):
|
||
self.theCounts = [0]*self.theLength
|
||
return
|
||
|
||
def countEntry(self, theLabel):
|
||
theIndex = self.lookupEntry(theLabel)
|
||
if theIndex is not None:
|
||
self.theCounts[theIndex] += 1
|
||
return
|
||
|
||
def packEntries(self, xParent):
|
||
"""Pack the status entries into an XML object for saving to the
|
||
main project file.
|
||
"""
|
||
for n in range(self.theLength):
|
||
xSub = etree.SubElement(xParent,"entry",attrib={
|
||
"blue" : str(self.theColours[n][2]),
|
||
"green" : str(self.theColours[n][1]),
|
||
"red" : str(self.theColours[n][0]),
|
||
})
|
||
xSub.text = self.theLabels[n]
|
||
return True
|
||
|
||
def unpackEntries(self, xParent):
|
||
"""Unpack an XML tree and set the class values.
|
||
"""
|
||
|
||
theLabels = []
|
||
theColours = []
|
||
|
||
for xChild in xParent:
|
||
theLabels.append(xChild.text)
|
||
if "red" in xChild.attrib:
|
||
cR = checkInt(xChild.attrib["red"],0,False)
|
||
else:
|
||
cR = 0
|
||
if "green" in xChild.attrib:
|
||
cG = checkInt(xChild.attrib["green"],0,False)
|
||
else:
|
||
cG = 0
|
||
if "blue" in xChild.attrib:
|
||
cB = checkInt(xChild.attrib["blue"],0,False)
|
||
else:
|
||
cB = 0
|
||
theColours.append((cR,cG,cB))
|
||
|
||
if len(theLabels) > 0:
|
||
self.theLabels = []
|
||
self.theColours = []
|
||
self.theCounts = []
|
||
self.theMap = {}
|
||
self.theLength = 0
|
||
self.theIndex = 0
|
||
|
||
for n in range(len(theLabels)):
|
||
self.addEntry(theLabels[n], theColours[n])
|
||
|
||
return True
|
||
|
||
##
|
||
# Iterator Bits
|
||
##
|
||
|
||
def __getitem__(self, n):
|
||
if n >= 0 and n < self.theLength:
|
||
return self.theLabels[n], self.theColours[n], self.theCounts[n]
|
||
return None, None, None
|
||
|
||
def __iter__(self):
|
||
self.theIndex = 0
|
||
return self
|
||
|
||
def __next__(self):
|
||
if self.theIndex < self.theLength:
|
||
theLabel, theColour, theCount = self.__getitem__(self.theIndex)
|
||
self.theIndex += 1
|
||
return theLabel, theColour, theCount
|
||
else:
|
||
raise StopIteration
|
||
|
||
# END Class NWStatus
|