# -*- coding: utf-8 -*- """novelWriter Project Wrapper novelWriter – Project Wrapper =============================== Class holding a project File History: Created: 2018-09-29 [0.0.1] NWProject Added: 2018-10-27 [0.0.1] NWItem Added: 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 . """ import logging import nw from os import path, mkdir, listdir, unlink, rename from lxml import etree from hashlib import sha256 from datetime import datetime from time import time from shutil import make_archive from nw.gui.tools import OptionState from nw.core.tools import projectMaintenance from nw.common import checkString, checkBool, checkInt from nw.constants import ( nwFiles, nwConst, 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 = None # The time stamp of when the project file was opened self.projChanged = None # The project has unsaved changes self.projAltered = None # 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 = None self.bookTitle = None self.bookAuthors = None # Various self.autoReplace = None # Project Settings self.spellCheck = False self.autoOutline = True self.statusItems = None self.importItems = None self.lastEdited = None self.lastViewed = None self.lastWCount = 0 self.currWCount = 0 self.doBackup = True # 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 = None self.projChanged = None 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.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 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" fileVersion = "Unknown" self.saveCount = 0 self.autoCount = 0 if "appVersion" in xRoot.attrib: appVersion = xRoot.attrib["appVersion"] 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 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 xChild.tag == "content": logger.debug("Found project content") for xItem in xChild: nwItem = NWItem(self) if nwItem.unpackXML(xItem): self.projTree.append(nwItem.itemHandle, nwItem.parHandle, nwItem) 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 if not self._checkFolder(self.projMeta): return 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__), "fileVersion" : "1.0", "saveCount" : str(self.saveCount), "autoCount" : str(self.autoCount), "timeStamp" : datetime.fromtimestamp(saveTime).strftime("%Y-%m-%d %H:%M:%S"), }) # Save Project Meta xProject = etree.SubElement(nwXML, "project") self._saveProjectValue(xProject, "name", self.projName, True) self._saveProjectValue(xProject, "title", self.bookTitle, True) self._saveProjectValue(xProject, "author", self.bookAuthors) self._saveProjectValue(xProject, "backup", self.doBackup) # Save Project Settings xSettings = etree.SubElement(nwXML, "settings") self._saveProjectValue(xSettings, "spellCheck", self.spellCheck) self._saveProjectValue(xSettings, "autoOutline", self.autoOutline) self._saveProjectValue(xSettings, "lastEdited", self.lastEdited) self._saveProjectValue(xSettings, "lastViewed", self.lastViewed) self._saveProjectValue(xSettings, "lastWordCount", self.currWCount) xAutoRep = etree.SubElement(xSettings, "autoReplace") for aKey, aValue in self.autoReplace.items(): if len(aKey) > 0: self._saveProjectValue(xAutoRep,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") xContent = etree.SubElement(nwXML, "content", attrib={"count":str(len(self.projTree))}) for tItem in self.projTree: tItem.packXML(xContent) # 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 = "" for c in self.projName.strip(): if c.isalpha() or c.isdigit() or c == " ": cleanName += c 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 on %s" % datetime.now().strftime("%Y-%m-%d at %H.%M.%S") 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): 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): self.projName = projName.strip() self.setProjectChanged(True) return True def setBookTitle(self, bookTitle): self.bookTitle = bookTitle.strip() self.setProjectChanged(True) return True def setBookAuthors(self, bookAuthors): 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): self.doBackup = False 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.ERROR) return False if self.projName == "": self.theParent.makeAlert(( "You must set a valid project name in project settings to " "use the automatic project backup feature." ), nwAlert.ERROR) return False self.doBackup = True return True def setSpellCheck(self, theMode): if self.spellCheck != theMode: self.spellCheck = theMode self.setProjectChanged(True) return True def setAutoOutline(self, theMode): if self.autoOutline != theMode: self.autoOutline = theMode self.setProjectChanged(True) return True def setTreeOrder(self, newOrder): 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): if self.lastEdited != tHandle: self.lastEdited = tHandle self.setProjectChanged(True) return True def setLastViewed(self, tHandle): if self.lastViewed != tHandle: self.lastViewed = tHandle self.setProjectChanged(True) return True def setProjectWordCount(self, theCount): if self.currWCount != theCount: self.currWCount = theCount self.setProjectChanged(True) return True def setStatusColours(self, newCols): replaceMap = self.statusItems.setNewEntries(newCols) if self.projTree: 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): replaceMap = self.importItems.setNewEntries(newCols) if self.projTree: 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): self.autoReplace = autoReplace return def setProjectChanged(self, bValue): 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 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. """ 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 _saveProjectValue(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 CML 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 nOrph = 0 for oHandle in orphanFiles: nOrph += 1 orphItem = NWItem(self) orphItem.setName("Orphaned File %d" % nOrph) 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 = datetime.fromtimestamp(self.projOpened).strftime(nwConst.tStampFmt), closed = datetime.now().strftime(nwConst.tStampFmt), 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 = "" # 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 = "" 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 ## # 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. """ 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 # 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,"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, "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 ## # 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