From 1c45331b0aadd9240e595381ece68f96d6dda19f Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Thu, 14 Oct 2021 20:35:42 +0100 Subject: [PATCH] Fix and optimise code (#904) * Fix bug in early error reporting in main init * Update docstrings and optimise code in GuiMain * Update docstrings and optimise code in Config * Update docstrings and optimise code in common module * Update docstrings and optimise code in main project classes * Update the OptionState class * Update the spell checker class * Update the file converter classes and extend tests * Update the about, merge, split and item editor classes and extend tests * Update item editor test * Update about dialog tests * Some minor test cleanup * Fix typo and add clarification in contributing guide --- CONTRIBUTING.md | 4 +- novelwriter/__init__.py | 20 +- novelwriter/common.py | 37 ++- novelwriter/config.py | 95 ++++-- novelwriter/core/index.py | 19 +- novelwriter/core/item.py | 21 +- novelwriter/core/options.py | 200 +++++-------- novelwriter/core/project.py | 17 +- novelwriter/core/spellcheck.py | 68 +++-- novelwriter/core/tohtml.py | 122 ++++---- novelwriter/core/tokenizer.py | 9 +- novelwriter/core/tomd.py | 24 +- novelwriter/core/toodt.py | 77 ++--- novelwriter/core/tree.py | 10 +- novelwriter/dialogs/about.py | 12 +- novelwriter/dialogs/docmerge.py | 2 +- novelwriter/dialogs/docsplit.py | 18 +- novelwriter/dialogs/itemeditor.py | 3 +- novelwriter/gui/doceditor.py | 2 +- novelwriter/guimain.py | 38 ++- .../guiItemEditor_Dialog_nwProject.nwx | 115 -------- tests/test_base/test_base_common.py | 18 +- tests/test_core/test_core_options.py | 6 +- tests/test_core/test_core_spell.py | 2 +- tests/test_core/test_core_tohtml.py | 110 +++---- tests/test_core/test_core_tomd.py | 277 ++++++++++++++++++ tests/test_core/test_core_toodt.py | 86 ++++++ tests/test_dialogs/test_dlg_about.py | 52 ++-- ...test_dlg_merge.py => test_dlg_docmerge.py} | 6 +- ...test_dlg_split.py => test_dlg_docsplit.py} | 8 +- tests/test_dialogs/test_dlg_itemeditor.py | 230 +++++++++++---- 31 files changed, 1042 insertions(+), 666 deletions(-) delete mode 100644 tests/reference/guiItemEditor_Dialog_nwProject.nwx create mode 100644 tests/test_core/test_core_tomd.py rename tests/test_dialogs/{test_dlg_merge.py => test_dlg_docmerge.py} (98%) rename tests/test_dialogs/{test_dlg_split.py => test_dlg_docsplit.py} (99%) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6e1173fe..c2fc9cc8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -76,13 +76,13 @@ style guide, but with a few exceptions. Some key points are listed below. **Variable and Function Names** * PEP8 allows for camelCase for consistency with existing code. The Qt library uses camelCase, so - the Python the source code does too. + the Python source code does too. * The exception to the above is for constants. They should always be in upper snake case, like PEP8 states. **Spaces, Indentation and Alignment** -* Only indentation by 4 spaces is allowed. +* Only indentation by multiples of 4 spaces is allowed. * No trailing spaces should occur on any line in the source code, including empty lines. * Ideally, a function should end on the same indention level as it started. Exceptions are allowed if it makes the code easier to follow. diff --git a/novelwriter/__init__.py b/novelwriter/__init__.py index 74bbaaa0..4f0f376e 100644 --- a/novelwriter/__init__.py +++ b/novelwriter/__init__.py @@ -117,7 +117,7 @@ def main(sysArgs=None): # Valid Input Options shortOpt = "hv" - longOpt = [ + longOpt = [ "help", "version", "info", @@ -215,31 +215,31 @@ def main(sysArgs=None): errorCode = 0 if sys.hexversion < 0x030600f0: errorData.append( - "At least Python 3.6.0 is required, found %s." % CONFIG.verPyString + "At least Python 3.6.0 is required, found %s" % CONFIG.verPyString ) errorCode |= 4 if CONFIG.verQtValue < 50300: errorData.append( - "At least Qt5 version 5.3 is required, found %s." % CONFIG.verQtString + "At least Qt5 version 5.3 is required, found %s" % CONFIG.verQtString ) errorCode |= 8 if CONFIG.verPyQtValue < 50300: errorData.append( - "At least PyQt5 version 5.3 is required, found %s." % CONFIG.verPyQtString + "At least PyQt5 version 5.3 is required, found %s" % CONFIG.verPyQtString ) errorCode |= 16 try: import lxml # noqa: F401 except ImportError: - errorData.append("Python module 'lxml' is missing.") + errorData.append("Python module 'lxml' is missing") errorCode |= 32 if errorData: errApp = QApplication([]) - errMsg = QErrorMessage() - errMsg.resize(500, 300) - errMsg.showMessage(( + errDlg = QErrorMessage() + errDlg.resize(500, 300) + errDlg.showMessage(( "

A critical error has been encountered

" "

novelWriter cannot start due to the following issues:

" "

 - %s

" @@ -247,8 +247,8 @@ def main(sysArgs=None): ) % ( "
 - ".join(errorData) )) - for errMsg in errorData: - logger.critical(errMsg) + for errLine in errorData: + logger.critical(errLine) errApp.exec_() sys.exit(errorCode) diff --git a/novelwriter/common.py b/novelwriter/common.py index 7e699e97..92ab66c6 100644 --- a/novelwriter/common.py +++ b/novelwriter/common.py @@ -46,7 +46,7 @@ logger = logging.getLogger(__name__) # =============================================================================================== # def checkString(value, default, allowNone=False): - """Check if a variable is a string or a none. + """Check if a variable is a string or a None. """ if allowNone and (value is None or value == "None"): return None @@ -56,7 +56,7 @@ def checkString(value, default, allowNone=False): def checkInt(value, default, allowNone=False): - """Check if a variable is an integer or a none. + """Check if a variable is an integer or a None. """ if allowNone and (value is None or value == "None"): return None @@ -66,8 +66,19 @@ def checkInt(value, default, allowNone=False): return default +def checkFloat(value, default, allowNone=False): + """Check if a variable is a float or a None. + """ + if allowNone and (value is None or value == "None"): + return None + try: + return float(value) + except Exception: + return default + + def checkBool(value, default, allowNone=False): - """Check if a variable is a boolean or a none. + """Check if a variable is a boolean or a None. """ if allowNone and (value is None or value == "None"): return None @@ -211,7 +222,7 @@ def formatTime(tS): def parseTimeStamp(theStamp, default, allowNone=False): - """Parses a text representation of a time stamp and converts it into + """Parses a text representation of a timestamp and converts it into a float. Note that negative timestamps cause an OSError on Windows. See https://bugs.python.org/issue29097 """ @@ -228,7 +239,7 @@ def parseTimeStamp(theStamp, default, allowNone=False): # =============================================================================================== # def splitVersionNumber(value): - """Splits a version string on the form aa.bb.cc into major, minor + """Split a version string on the form aa.bb.cc into major, minor and patch, and computes an integer value aabbcc. """ if not isinstance(value, str): @@ -337,7 +348,7 @@ def fuzzyTime(secDiff): ).format(int(round(secDiff/31557600))) -def numberToRoman(numVal, isLower=False): +def numberToRoman(numVal, toLower=False): """Convert an integer to a Roman number. """ if not isinstance(numVal, int): @@ -358,7 +369,7 @@ def numberToRoman(numVal, isLower=False): if numVal <= 0: break - return romNum.lower() if isLower else romNum + return romNum.lower() if toLower else romNum # =============================================================================================== # @@ -434,11 +445,11 @@ def readTextFile(filePath): return fileText -def makeFileNameSafe(theText): - """Returns a filename safe version of the text. +def makeFileNameSafe(value): + """Returns a filename safe string of the value. """ cleanName = "" - for c in theText.strip(): + for c in str(value).strip(): if c.isalpha() or c.isdigit() or c == " ": cleanName += c return cleanName @@ -456,7 +467,7 @@ def sha256sum(filePath): for n in iter(lambda: inFile.readinto(mData), 0): hDigest.update(mData[:n]) except Exception: - logger.error("Could not read sha256sum of: %s", filePath) + logger.error("Could not create sha256sum of: %s", filePath) logException() return None @@ -467,11 +478,11 @@ def sha256sum(filePath): # Other Functions # =============================================================================================== # -def getGuiItem(theName): +def getGuiItem(objName): """Returns a QtWidget based on its objectName. """ for qWidget in qApp.topLevelWidgets(): - if qWidget.objectName() == theName: + if qWidget.objectName() == objName: return qWidget return None diff --git a/novelwriter/config.py b/novelwriter/config.py index 8a7231f8..6b342acc 100644 --- a/novelwriter/config.py +++ b/novelwriter/config.py @@ -302,7 +302,7 @@ class Config: logger.verbose("App path: %s", self.appPath) logger.verbose("Last path: %s", self.lastPath) - # If config folder does not exist, create it. + # If the config folder does not exist, create it. # This assumes that the os config folder itself exists. if not os.path.isdir(self.confPath): try: @@ -324,7 +324,7 @@ class Config: # If it does not exist, save a copy of the default values self.saveConfig() - # If data folder does not exist, make it. + # If the data folder does not exist, create it. # This assumes that the os data folder itself exists. if self.dataPath is not None: if not os.path.isdir(self.dataPath): @@ -534,7 +534,7 @@ class Config: logger.info("Using straight single quotes, so disabling auto-replace") self.doReplaceSQuote = False - if self.fmtDoubleQuotes == ["\"", "\""] and self.doReplaceDQuote: + if self.fmtDoubleQuotes == ['"', '"'] and self.doReplaceDQuote: logger.info("Using straight double quotes, so disabling auto-replace") self.doReplaceDQuote = False @@ -671,36 +671,28 @@ class Config: if self.dataPath is None: return False - cacheFile = os.path.join(self.dataPath, nwFiles.RECENT_FILE) self.recentProj = {} - if os.path.isfile(cacheFile): - try: - with open(cacheFile, mode="r", encoding="utf-8") as inFile: - theData = json.load(inFile) + cacheFile = os.path.join(self.dataPath, nwFiles.RECENT_FILE) + if not os.path.isfile(cacheFile): + return True - for projPath in theData.keys(): - theEntry = theData[projPath] - theTitle = "" - lastTime = 0 - wordCount = 0 - if "title" in theEntry.keys(): - theTitle = theEntry["title"] - if "time" in theEntry.keys(): - lastTime = int(theEntry["time"]) - if "words" in theEntry.keys(): - wordCount = int(theEntry["words"]) - self.recentProj[projPath] = { - "title": theTitle, - "time": lastTime, - "words": wordCount, - } + try: + with open(cacheFile, mode="r", encoding="utf-8") as inFile: + theData = json.load(inFile) - except Exception as e: - self.hasError = True - self.errData.append("Could not load recent project cache") - self.errData.append(str(e)) - return False + for projPath, theEntry in theData.items(): + self.recentProj[projPath] = { + "title": theEntry.get("title", ""), + "time": theEntry.get("time", 0), + "words": theEntry.get("words", 0), + } + + except Exception as e: + self.hasError = True + self.errData.append("Could not load recent project cache") + self.errData.append(str(e)) + return False return True @@ -755,6 +747,8 @@ class Config: ## def setConfPath(self, newPath): + """Set the path and filename to the config file. + """ if newPath is None: return True if not os.path.isfile(newPath): @@ -765,6 +759,8 @@ class Config: return True def setDataPath(self, newPath): + """Set the data path. + """ if newPath is None: return True if not os.path.isdir(newPath): @@ -774,6 +770,8 @@ class Config: return True def setLastPath(self, lastPath): + """Set the last used path (by the user). + """ if lastPath is None or lastPath == "": self.lastPath = "" else: @@ -781,6 +779,11 @@ class Config: return True def setWinSize(self, newWidth, newHeight): + """Set the size of the main window, but only if the change is + larger than 5 pixels. The OS window manager will sometimes + adjust it a bit, and we don't want the main window to shrink or + grow each time the app is opened. + """ newWidth = int(newWidth/self.guiScale) newHeight = int(newHeight/self.guiScale) if abs(self.winGeometry[0] - newWidth) > 5: @@ -792,57 +795,79 @@ class Config: return True def setPreferencesSize(self, newWidth, newHeight): + """Sat the size of the Preferences dialog window. + """ self.prefGeometry[0] = int(newWidth/self.guiScale) self.prefGeometry[1] = int(newHeight/self.guiScale) self.confChanged = True return True def setTreeColWidths(self, colWidths): + """Set the column widths of the main project tree. + """ self.treeColWidth = [int(x/self.guiScale) for x in colWidths] self.confChanged = True return True def setNovelColWidths(self, colWidths): + """Set the column widths of the novel tree. + """ self.novelColWidth = [int(x/self.guiScale) for x in colWidths] self.confChanged = True return True def setProjColWidths(self, colWidths): + """Set the column widths of the Load Project dialog. + """ self.projColWidth = [int(x/self.guiScale) for x in colWidths] self.confChanged = True return True def setMainPanePos(self, panePos): + """Set the position of the main GUI splitter. + """ self.mainPanePos = [int(x/self.guiScale) for x in panePos] self.confChanged = True return True def setDocPanePos(self, panePos): + """Set the position of the main editor/viewer splitter. + """ self.docPanePos = [int(x/self.guiScale) for x in panePos] self.confChanged = True return True def setViewPanePos(self, panePos): + """Set the position of the viewer meta data splitter. + """ self.viewPanePos = [int(x/self.guiScale) for x in panePos] self.confChanged = True return True def setOutlinePanePos(self, panePos): + """Set the position of the outline details splitter. + """ self.outlnPanePos = [int(x/self.guiScale) for x in panePos] self.confChanged = True return True def setShowRefPanel(self, checkState): + """Set the visibility state of the reference panel. + """ self.showRefPanel = checkState self.confChanged = True return self.showRefPanel def setViewComments(self, viewState): + """Set the visibility state of comments in the viewer. + """ self.viewComments = viewState self.confChanged = True return self.viewComments def setViewSynopsis(self, viewState): + """Set the visibility state of synopsis comments in the viewer. + """ self.viewSynopsis = viewState self.confChanged = True return self.viewSynopsis @@ -852,12 +877,18 @@ class Config: ## def setDefaultGuiTheme(self): + """Reset the GUI theme to default value. + """ self.guiTheme = "default" def setDefaultSyntaxTheme(self): + """Reset the syntax theme to default value. + """ self.guiSyntax = "default_light" def setDefaultIconTheme(self): + """Reset the icon theme to default value. + """ self.guiIcons = "typicons_light" ## @@ -904,6 +935,9 @@ class Config: return self.pxInt(self.focusWidth) def getErrData(self): + """Compile and return error messages from the initialisation of + the Config class, and clear the error buffer. + """ errMessage = "
".join(self.errData) self.hasError = False self.errData = [] @@ -914,7 +948,8 @@ class Config: ## def _packList(self, inData): - """Pack a list of items into a comma-separated string. + """Pack a list of items into a comma-separated string for saving + to the config file. """ return ", ".join([str(inVal) for inVal in inData]) diff --git a/novelwriter/core/index.py b/novelwriter/core/index.py index 55839ff4..77e75b0c 100644 --- a/novelwriter/core/index.py +++ b/novelwriter/core/index.py @@ -287,10 +287,8 @@ class NWIndex(): nLine = 0 nTitle = 0 theLines = theText.splitlines() - for aLine in theLines: - nLine += 1 - nChar = len(aLine.strip()) - if nChar == 0: + for nLine, aLine in enumerate(theLines, start=1): + if len(aLine.strip()) == 0: continue if aLine.startswith("#"): @@ -363,7 +361,7 @@ class NWIndex(): else: return False - sTitle = "T%06d" % nLine + sTitle = f"T{nLine:06d}" self._fileIndex[tHandle][sTitle] = { "level": hDepth, "title": hText, @@ -391,14 +389,13 @@ class NWIndex(): "pCount": 0, "synopsis": "", } - return def _indexWordCounts(self, tHandle, theText, nTitle): """Count text stats and save the counts to the index. """ cC, wC, pC = countWords(theText) - sTitle = "T%06d" % nTitle + sTitle = f"T{nTitle:06d}" if tHandle in self._fileIndex: if sTitle in self._fileIndex[tHandle]: self._fileIndex[tHandle][sTitle]["cCount"] = cC @@ -409,7 +406,7 @@ class NWIndex(): def _indexSynopsis(self, tHandle, theText, nTitle): """Save the synopsis to the index. """ - sTitle = "T%06d" % nTitle + sTitle = f"T{nTitle:06d}" if tHandle in self._fileIndex: if sTitle in self._fileIndex[tHandle]: self._fileIndex[tHandle][sTitle]["synopsis"] = theText @@ -428,7 +425,7 @@ class NWIndex(): logger.warning("Skipping invalid keyword '%s' in '%s'", theBits[0], tHandle) return - sTitle = "T%06d" % nTitle + sTitle = f"T{nTitle:06d}" if theBits[0] == nwKeyWords.TAG_KEY: self._tagIndex[theBits[1]] = [nLine, tHandle, itemClass.name, sTitle] @@ -525,7 +522,7 @@ class NWIndex(): """ for tHandle in self._listNovelHandles(skipExcluded): for sTitle in sorted(self._fileIndex[tHandle]): - tKey = "%s:%s" % (tHandle, sTitle) + tKey = f"{tHandle}:{sTitle}" yield tKey, tHandle, sTitle, self._fileIndex[tHandle][sTitle] def getNovelWordCount(self, skipExcluded=True): @@ -559,7 +556,7 @@ class NWIndex(): return theCounts for sTitle, sData in hRecord.items(): - theCounts.append(("%s:%s" % (tHandle, sTitle), sData["wCount"])) + theCounts.append((f"{tHandle}:{sTitle}", sData["wCount"])) return theCounts diff --git a/novelwriter/core/item.py b/novelwriter/core/item.py index 3170c6cd..985a9e8c 100644 --- a/novelwriter/core/item.py +++ b/novelwriter/core/item.py @@ -103,11 +103,8 @@ class NWItem(): logger.error("XML item entry does not have a handle") return False - if "parent" in xItem.attrib: - self.setParent(xItem.attrib["parent"]) - - if "order" in xItem.attrib: - self.setOrder(xItem.attrib["order"]) + self.setParent(xItem.attrib.get("parent", None)) + self.setOrder(xItem.attrib.get("order", 0)) tmpStatus = "" for xValue in xItem: @@ -200,11 +197,8 @@ class NWItem(): def setHandle(self, theHandle): """Set the item handle, and ensure it is valid. """ - if isinstance(theHandle, str): - if isHandle(theHandle): - self.itemHandle = theHandle - else: - self.itemHandle = None + if isHandle(theHandle): + self.itemHandle = theHandle else: self.itemHandle = None return @@ -214,11 +208,8 @@ class NWItem(): """ if theParent is None: self.itemParent = None - elif isinstance(theParent, str): - if isHandle(theParent): - self.itemParent = theParent - else: - self.itemParent = None + elif isHandle(theParent): + self.itemParent = theParent else: self.itemParent = None return diff --git a/novelwriter/core/options.py b/novelwriter/core/options.py index 3526d4f8..424fe659 100644 --- a/novelwriter/core/options.py +++ b/novelwriter/core/options.py @@ -30,88 +30,40 @@ import logging import novelwriter from novelwriter.constants import nwFiles +from novelwriter.common import checkBool, checkFloat, checkInt, checkString logger = logging.getLogger(__name__) +VALID_MAP = { + "GuiWritingStats": { + "winWidth", "winHeight", "widthCol0", "widthCol1", "widthCol2", + "widthCol3", "sortCol", "sortOrder", "incNovel", "incNotes", + "hideZeros", "hideNegative", "groupByDay", "showIdleTime", "histMax" + }, + "GuiDocSplit": {"spLevel"}, + "GuiBuildNovel": { + "winWidth", "winHeight", "boxWidth", "docWidth", "addNovel", + "addNotes", "ignoreFlag", "justifyText", "excludeBody", "textFont", + "textSize", "lineHeight", "noStyling", "incSynopsis", "incComments", + "incKeywords", "incBodyText", "replaceTabs", "replaceUCode" + }, + "GuiOutline": {"headerOrder", "columnWidth", "columnHidden"}, + "GuiProjectSettings": { + "winWidth", "winHeight", "replaceColW", "statusColW", "importColW" + }, + "GuiProjectDetails": { + "winWidth", "winHeight", "widthCol0", "widthCol1", "widthCol2", + "widthCol3", "widthCol4", "wordsPerPage", "countFrom", "clearDouble" + }, + "GuiWordList": {"winWidth", "winHeight"} +} + class OptionState(): def __init__(self, theProject): - self.theProject = theProject - self.theState = {} - self.validMap = { - "GuiWritingStats": { - "winWidth", - "winHeight", - "widthCol0", - "widthCol1", - "widthCol2", - "widthCol3", - "sortCol", - "sortOrder", - "incNovel", - "incNotes", - "hideZeros", - "hideNegative", - "groupByDay", - "showIdleTime", - "histMax", - }, - "GuiDocSplit": { - "spLevel", - }, - "GuiBuildNovel": { - "winWidth", - "winHeight", - "boxWidth", - "docWidth", - "addNovel", - "addNotes", - "ignoreFlag", - "justifyText", - "excludeBody", - "textFont", - "textSize", - "lineHeight", - "noStyling", - "incSynopsis", - "incComments", - "incKeywords", - "incBodyText", - "replaceTabs", - "replaceUCode", - }, - "GuiOutline": { - "headerOrder", - "columnWidth", - "columnHidden", - }, - "GuiProjectSettings": { - "winWidth", - "winHeight", - "replaceColW", - "statusColW", - "importColW", - }, - "GuiProjectDetails": { - "winWidth", - "winHeight", - "widthCol0", - "widthCol1", - "widthCol2", - "widthCol3", - "widthCol4", - "wordsPerPage", - "countFrom", - "clearDouble", - }, - "GuiWordList": { - "winWidth", - "winHeight", - } - } - + self._theState = {} return ## @@ -139,11 +91,11 @@ class OptionState(): # Filter out unused variables for aGroup in theState: - if aGroup in self.validMap: - self.theState[aGroup] = {} + if aGroup in VALID_MAP: + self._theState[aGroup] = {} for anOpt in theState[aGroup]: - if anOpt in self.validMap[aGroup]: - self.theState[aGroup][anOpt] = theState[aGroup][anOpt] + if anOpt in VALID_MAP[aGroup]: + self._theState[aGroup][anOpt] = theState[aGroup][anOpt] return True @@ -158,7 +110,7 @@ class OptionState(): try: with open(stateFile, mode="w+", encoding="utf-8") as outFile: - json.dump(self.theState, outFile, indent=2) + json.dump(self._theState, outFile, indent=2) except Exception: logger.error("Failed to save GUI options file") novelwriter.logException() @@ -170,21 +122,21 @@ class OptionState(): # Setters ## - def setValue(self, setGroup, setName, setValue): + def setValue(self, group, name, value): """Saves a value, with a given group and name. """ - if setGroup not in self.validMap: - logger.error("Unknown option group '%s'", setGroup) + if group not in VALID_MAP: + logger.error("Unknown option group '%s'", group) return False - if setName not in self.validMap[setGroup]: - logger.error("Unknown option name '%s'", setName) + if name not in VALID_MAP[group]: + logger.error("Unknown option name '%s'", name) return False - if setGroup not in self.theState: - self.theState[setGroup] = {} + if group not in self._theState: + self._theState[group] = {} - self.theState[setGroup][setName] = setValue + self._theState[group][name] = value return True @@ -192,79 +144,67 @@ class OptionState(): # Getters ## - def getValue(self, getGroup, getName, defaultValue): + def getValue(self, group, name, default): """Return an arbitrary type value, if it exists. Otherwise, return the default value. """ - if getGroup in self.theState: - if getName in self.theState[getGroup]: - return self.theState[getGroup][getName] - return defaultValue + if group in self._theState: + return self._theState[group].get(name, default) + return default - def getString(self, getGroup, getName, defaultValue): + def getString(self, group, name, default): """Return the value as a string, if it exists. Otherwise, return the default value. """ - if getGroup in self.theState: - if getName in self.theState[getGroup]: - return str(self.theState[getGroup][getName]) - return defaultValue + if group in self._theState: + return checkString(self._theState[group].get(name, default), default) + return default - def getInt(self, getGroup, getName, defaultValue): + def getInt(self, group, name, default): """Return the value as an int, if it exists. Otherwise, return the default value. """ - if getGroup in self.theState: - if getName in self.theState[getGroup]: - try: - return int(self.theState[getGroup][getName]) - except Exception as e: - logger.warning(str(e)) - return defaultValue - return defaultValue + if group in self._theState: + return checkInt(self._theState[group].get(name, default), default) + return default - def getFloat(self, getGroup, getName, defaultValue): + def getFloat(self, group, name, default): """Return the value as a float, if it exists. Otherwise, return the default value. """ - if getGroup in self.theState: - if getName in self.theState[getGroup]: - try: - return float(self.theState[getGroup][getName]) - except Exception as e: - logger.warning(str(e)) - return defaultValue - return defaultValue + if group in self._theState: + return checkFloat(self._theState[group].get(name, default), default) + return default - def getBool(self, getGroup, getName, defaultValue): + def getBool(self, group, name, default): """Return the value as a bool, if it exists. Otherwise, return the default value. """ - if getGroup in self.theState: - if getName in self.theState[getGroup]: - return bool(self.theState[getGroup][getName]) - return defaultValue + if group in self._theState: + if name in self._theState[group]: + return checkBool(self._theState[group].get(name, default), default) + return default ## # Validators ## - def validIntRange(self, theValue, intA, intB, intDefault): + def validIntRange(self, value, first, last, default): """Check that an int is in a given range. If it isn't, return the default value. """ - if isinstance(theValue, int): - if theValue >= intA and theValue <= intB: - return theValue - return intDefault + if isinstance(value, int): + if value >= first and value <= last: + return value + return default - def validIntTuple(self, theValue, theTuple, intDefault): + def validIntTuple(self, value, valid, default): """Check that an int is an element of a tuple. If it isn't, return the default value. """ - if isinstance(theValue, int): - if theValue in theTuple: - return theValue - return intDefault + if isinstance(value, int): + if value in valid: + return value + return default # END Class OptionState diff --git a/novelwriter/core/project.py b/novelwriter/core/project.py index a1a70bda..98050051 100644 --- a/novelwriter/core/project.py +++ b/novelwriter/core/project.py @@ -125,6 +125,7 @@ class NWProject(): if not self.projTree.checkRootUnique(rootClass): self.theParent.makeAlert(self.tr("Duplicate root item detected."), nwAlert.ERROR) return None + newItem = NWItem(self) newItem.setName(rootName) newItem.setType(nwItemType.ROOT) @@ -356,7 +357,7 @@ class NWProject(): return True def openProject(self, fileName, overrideLock=False): - """Open the project file provided, or if doesn't exist, assume + """Open the project file provided. If it 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. @@ -469,8 +470,8 @@ class NWProject(): # 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 onlye two. One for - # novel documents and one for notes. Introduced in version - # 1.5. + # novel documents and one for project notes. Introduced in + # version 1.5. if fileVersion not in ("1.0", "1.1", "1.2", "1.3"): self.theParent.makeAlert(self.tr( @@ -973,7 +974,7 @@ class NWProject(): return False self.bookAuthors = [] - for bookAuthor in bookAuthors.split("\n"): + for bookAuthor in bookAuthors.splitlines(): bookAuthor = bookAuthor.strip() if bookAuthor == "": continue @@ -1074,7 +1075,7 @@ class NWProject(): replaceMap = self.statusItems.setNewEntries(newCols) for nwItem in self.projTree: if nwItem.itemClass == nwItemClass.NOVEL: - if nwItem.itemStatus in replaceMap.keys(): + if nwItem.itemStatus in replaceMap: nwItem.setStatus(replaceMap[nwItem.itemStatus]) self.setProjectChanged(True) return True @@ -1086,7 +1087,7 @@ class NWProject(): replaceMap = self.importItems.setNewEntries(newCols) for nwItem in self.projTree: if nwItem.itemClass != nwItemClass.NOVEL: - if nwItem.itemStatus in replaceMap.keys(): + if nwItem.itemStatus in replaceMap: nwItem.setStatus(replaceMap[nwItem.itemStatus]) self.setProjectChanged(True) return True @@ -1104,7 +1105,7 @@ class NWProject(): """ for valKey, valEntry in titleFormat.items(): if valKey in self.titleFormat: - self.titleFormat[valKey] = checkString(valEntry, self.titleFormat[valKey], False) + self.titleFormat[valKey] = checkString(valEntry, self.titleFormat[valKey]) return True def setProjectChanged(self, bValue): @@ -1346,7 +1347,7 @@ class NWProject(): return def _packProjectKeyValue(self, xParent, theName, theDict): - """Pack the entries in the auto-replace dictionary. + """Pack the entries of a dictionary into an xml element. """ xAutoRep = etree.SubElement(xParent, theName) for aKey, aValue in theDict.items(): diff --git a/novelwriter/core/spellcheck.py b/novelwriter/core/spellcheck.py index 580326b1..dcdbe089 100644 --- a/novelwriter/core/spellcheck.py +++ b/novelwriter/core/spellcheck.py @@ -35,16 +35,24 @@ class NWSpellEnchant(): def __init__(self): self.mainConf = novelwriter.CONFIG - self.theDict = None - self.projDict = set() - self.projectDict = None - self.spellLanguage = None - self.theBroker = None + + self._theDict = None + self._projDict = set() + self._projectDict = None + self._spellLanguage = None + self._theBroker = None logger.debug("Enchant spell checking activated") return + ## + # Getters and Setters + ## + + def spellLanguage(self): + return self._spellLanguage + def setLanguage(self, theLang, projectDict=None): """Load a dictionary for the language specified in the config. If that fails, we load a mock dictionary so that lookups don't @@ -52,49 +60,53 @@ class NWSpellEnchant(): """ try: import enchant - if self.theBroker is not None: + if self._theBroker is not None: logger.debug("Deleting old pyenchant broker") - del self.theBroker + del self._theBroker - self.theBroker = enchant.Broker() - self.theDict = self.theBroker.request_dict(theLang) - self.spellLanguage = theLang + self._theBroker = enchant.Broker() + self._theDict = self._theBroker.request_dict(theLang) + self._spellLanguage = theLang logger.debug("Enchant spell checking for language '%s' loaded", theLang) except Exception: logger.error("Failed to load enchant spell checking for language '%s'", theLang) - self.theDict = FakeEnchant() - self.spellLanguage = None + self._theDict = FakeEnchant() + self._spellLanguage = None self._readProjectDictionary(projectDict) - for pWord in self.projDict: - self.theDict.add_to_session(pWord) + for pWord in self._projDict: + self._theDict.add_to_session(pWord) return + ## + # Methods + ## + def checkWord(self, theWord): """Wrapper function for pyenchant. """ - return self.theDict.check(theWord) + return self._theDict.check(theWord) def suggestWords(self, theWord): """Wrapper function for pyenchant. """ - return self.theDict.suggest(theWord) + return self._theDict.suggest(theWord) def addWord(self, newWord): """Add a word to the project dictionary. """ - self.theDict.add_to_session(newWord) + self._theDict.add_to_session(newWord) - if self.projectDict is not None and newWord not in self.projDict: + if self._projectDict is not None and newWord not in self._projDict: newWord = newWord.strip() try: - with open(self.projectDict, mode="a+", encoding="utf-8") as outFile: + with open(self._projectDict, mode="a+", encoding="utf-8") as outFile: outFile.write("%s\n" % newWord) - self.projDict.add(newWord) + self._projDict.add(newWord) except Exception: - logger.error("Failed to add word to project word list %s", str(self.projectDict)) + logger.error("Failed to add word to project word list %s", str(self._projectDict)) novelwriter.logException() return False return True @@ -119,8 +131,8 @@ class NWSpellEnchant(): dictionary. """ try: - spTag = self.theDict.tag - spName = self.theDict.provider.name + spTag = self._theDict.tag + spName = self._theDict.provider.name except Exception: logger.error("Failed to extract information about the dictionary") novelwriter.logException() @@ -137,8 +149,8 @@ class NWSpellEnchant(): """Read the content of the project dictionary, and add it to the lookup lists. """ - self.projDict = set() - self.projectDict = projectDict + self._projDict = set() + self._projectDict = projectDict if projectDict is None: return False @@ -151,9 +163,9 @@ class NWSpellEnchant(): with open(projectDict, mode="r", encoding="utf-8") as wordsFile: for theLine in wordsFile: theLine = theLine.strip() - if len(theLine) > 0 and theLine not in self.projDict: - self.projDict.add(theLine) - logger.debug("Project word list contains %d words", len(self.projDict)) + if len(theLine) > 0 and theLine not in self._projDict: + self._projDict.add(theLine) + logger.debug("Project word list contains %d words", len(self._projDict)) except Exception: logger.error("Failed to load project word list") diff --git a/novelwriter/core/tohtml.py b/novelwriter/core/tohtml.py index b6af70f3..7e9e4a74 100644 --- a/novelwriter/core/tohtml.py +++ b/novelwriter/core/tohtml.py @@ -109,7 +109,7 @@ class ToHtml(Tokenizer): to theResult. """ if self.genMode == self.M_PREVIEW: - htmlTags = { # HTML4 + CSS2 + htmlTags = { # HTML4 + CSS2 (for Qt) self.FMT_B_B: "", self.FMT_B_E: "", self.FMT_I_B: "", @@ -118,7 +118,7 @@ class ToHtml(Tokenizer): self.FMT_D_E: "", } else: - htmlTags = { # HTML5 + htmlTags = { # HTML5 (for export) self.FMT_B_B: "", self.FMT_B_E: "", self.FMT_I_B: "", @@ -176,17 +176,18 @@ class ToHtml(Tokenizer): aStyle.append("margin-top: 0;") if tStyle & self.A_IND_L: - aStyle.append("margin-left: %dpx;" % self.mainConf.tabWidth) + aStyle.append(f"margin-left: {self.mainConf.tabWidth:d}px;") if tStyle & self.A_IND_R: - aStyle.append("margin-right: %dpx;" % self.mainConf.tabWidth) + aStyle.append(f"margin-right: {self.mainConf.tabWidth:d}px;") if len(aStyle) > 0: - hStyle = " style='%s'" % (" ".join(aStyle)) + stVals = " ".join(aStyle) + hStyle = f" style='{stVals}'" else: hStyle = "" if self.linkHeaders: - aNm = "" % tLine + aNm = f"" else: aNm = "" @@ -200,36 +201,36 @@ class ToHtml(Tokenizer): parClass = "" if len(thisPar) > 0: tTemp = "
".join(thisPar) - tmpResult.append("%s

\n" % (parClass, parStyle, tTemp.rstrip())) + tmpResult.append(f"{tTemp.rstrip()}

\n") thisPar = [] parStyle = None elif tType == self.T_TITLE: tHead = tText.replace(r"\\", "
") - tmpResult.append("

%s%s

\n" % (hStyle, aNm, tHead)) + tmpResult.append(f"

{aNm}{tHead}

\n") elif tType == self.T_UNNUM: tHead = tText.replace(r"\\", "
") - tmpResult.append("<%s%s>%s%s\n" % (h2, hStyle, aNm, tHead, h2)) + tmpResult.append(f"<{h2}{hStyle}>{aNm}{tHead}\n") elif tType == self.T_HEAD1: tHead = tText.replace(r"\\", "
") - tmpResult.append("<%s%s%s>%s%s\n" % (h1, h1Cl, hStyle, aNm, tHead, h1)) + tmpResult.append(f"<{h1}{h1Cl}{hStyle}>{aNm}{tHead}\n") elif tType == self.T_HEAD2: tHead = tText.replace(r"\\", "
") - tmpResult.append("<%s%s>%s%s\n" % (h2, hStyle, aNm, tHead, h2)) + tmpResult.append(f"<{h2}{hStyle}>{aNm}{tHead}\n") elif tType == self.T_HEAD3: tHead = tText.replace(r"\\", "
") - tmpResult.append("<%s%s>%s%s\n" % (h3, hStyle, aNm, tHead, h3)) + tmpResult.append(f"<{h3}{hStyle}>{aNm}{tHead}\n") elif tType == self.T_HEAD4: tHead = tText.replace(r"\\", "
") - tmpResult.append("<%s%s>%s%s\n" % (h4, hStyle, aNm, tHead, h4)) + tmpResult.append(f"<{h4}{hStyle}>{aNm}{tHead}\n") elif tType == self.T_SEP: - tmpResult.append("

%s

\n" % tText) + tmpResult.append(f"

{tText}

\n") elif tType == self.T_SKIP: tmpResult.append("

 

\n") @@ -249,7 +250,7 @@ class ToHtml(Tokenizer): tmpResult.append(self._formatComments(tText)) elif tType == self.T_KEYWORD and self.doKeywords: - tTemp = "%s

\n" % (hStyle, self._formatKeywords(tText)) + tTemp = f"{self._formatKeywords(tText)}

\n" tmpResult.append(tTemp) self.theResult = "".join(tmpResult) @@ -298,9 +299,9 @@ class ToHtml(Tokenizer): """Replace tabs with spaces in the html. """ htmlText = [] - eightSpace = spaceChar*nSpaces + tabSpace = spaceChar*nSpaces for aLine in self.fullHTML: - htmlText.append(aLine.replace("\t", eightSpace)) + htmlText.append(aLine.replace("\t", tabSpace)) self.fullHTML = htmlText return @@ -315,75 +316,76 @@ class ToHtml(Tokenizer): mScale = self.lineHeight/1.15 textAlign = "justify" if self.doJustify else "left" - theStyles.append("body {font-family: '%s'; font-size: %dpt;}" % ( + theStyles.append("body {{font-family: '{0:s}'; font-size: {1:d}pt;}}".format( self.textFont, self.textSize )) theStyles.append(( - "p {" - "text-align: %s; line-height: %d%%; " - "margin-top: %.2fem; margin-bottom: %.2fem;" - "}" - ) % ( + "p {{" + "text-align: {0}; line-height: {1:d}%; " + "margin-top: {2:.2f}em; margin-bottom: {3:.2f}em;" + "}}" + ).format( textAlign, round(100 * self.lineHeight), mScale * self.marginText[0], mScale * self.marginText[1], )) theStyles.append(( - "h1 {" + "h1 {{" "color: rgb(66, 113, 174); " "page-break-after: avoid; " - "margin-top: %.2fem; " - "margin-bottom: %.2fem;" - "}" - ) % ( + "margin-top: {0:.2f}em; " + "margin-bottom: {1:.2f}em;" + "}}" + ).format( mScale * self.marginHead1[0], mScale * self.marginHead1[1] )) theStyles.append(( - "h2 {" + "h2 {{" "color: rgb(66, 113, 174); " "page-break-after: avoid; " - "margin-top: %.2fem; " - "margin-bottom: %.2fem;" - "}" - ) % ( + "margin-top: {0:.2f}em; " + "margin-bottom: {1:.2f}em;" + "}}" + ).format( mScale * self.marginHead2[0], mScale * self.marginHead2[1] )) theStyles.append(( - "h3 {" + "h3 {{" "color: rgb(50, 50, 50); " "page-break-after: avoid; " - "margin-top: %.2fem; " - "margin-bottom: %.2fem;" - "}" - ) % ( + "margin-top: {0:.2f}em; " + "margin-bottom: {1:.2f}em;" + "}}" + ).format( mScale * self.marginHead3[0], mScale * self.marginHead3[1] )) theStyles.append(( - "h4 {" + "h4 {{" "color: rgb(50, 50, 50); " "page-break-after: avoid; " - "margin-top: %.2fem; " - "margin-bottom: %.2fem;" - "}" - ) % ( + "margin-top: {0:.2f}em; " + "margin-bottom: {1:.2f}em;" + "}}" + ).format( mScale * self.marginHead4[0], mScale * self.marginHead4[1] )) theStyles.append(( - ".title {" + ".title {{" "font-size: 2.5em; " - "margin-top: %.2fem; " - "margin-bottom: %.2fem;" - "}" - ) % ( + "margin-top: {0:.2f}em; " + "margin-bottom: {1:.2f}em;" + "}}" + ).format( mScale * self.marginTitle[0], mScale * self.marginTitle[1] )) theStyles.append(( - ".sep, .skip {" + ".sep, .skip {{" "text-align: center; " - "margin-top: %.2fem; " - "margin-bottom: %.2fem;}" - ) % ( + "margin-top: {0:.2f}em; " + "margin-bottom: {1:.2f}em;" + "}}" + ).format( mScale, mScale )) @@ -421,31 +423,25 @@ class ToHtml(Tokenizer): def _formatKeywords(self, tText): """Apply HTML formatting to keywords. """ - isValid, theBits, thePos = self.theParent.theIndex.scanThis("@"+tText) + isValid, theBits, _ = self.theParent.theIndex.scanThis("@"+tText) if not isValid or not theBits: return "" retText = "" refTags = [] if theBits[0] in nwLabels.KEY_NAME: - retText += "%s: " % nwLabels.KEY_NAME[theBits[0]] + retText += f"{nwLabels.KEY_NAME[theBits[0]]}: " if len(theBits) > 1: if theBits[0] == nwKeyWords.TAG_KEY: - retText += "%s" % ( - theBits[1], theBits[1] - ) + retText += f"{theBits[1]}" else: if self.genMode == self.M_PREVIEW: for tTag in theBits[1:]: - refTags.append("%s" % ( - theBits[0][1:], tTag, tTag - )) + refTags.append(f"{tTag}") retText += ", ".join(refTags) else: for tTag in theBits[1:]: - refTags.append("%s" % ( - tTag, tTag - )) + refTags.append(f"{tTag}") retText += ", ".join(refTags) return retText diff --git a/novelwriter/core/tokenizer.py b/novelwriter/core/tokenizer.py index c4b1be5e..a95c3f41 100644 --- a/novelwriter/core/tokenizer.py +++ b/novelwriter/core/tokenizer.py @@ -267,13 +267,14 @@ class Tokenizer(): else: textAlign = self.A_PBB | self.A_CENTRE - theTitle = "%s: %s" % (self._localLookup("Notes"), theItem.itemName) + locNotes = self._localLookup("Notes") + theTitle = f"{locNotes}: {theItem.itemName}" self.theTokens = [] self.theTokens.append(( self.T_TITLE, 0, theTitle, None, textAlign )) if self.keepMarkdown: - self.theMarkdown.append("# %s\n\n" % theTitle) + self.theMarkdown.append(f"# {theTitle}\n\n") return True @@ -302,7 +303,7 @@ class Tokenizer(): errVal = self.tr("Document '{0}' is too big ({1} MB). Skipping.").format( self.theItem.itemName, f"{docSize/1.0e6:.2f}" ) - self.theText = "# %s\n\n%s\n\n" % (self.tr("ERROR"), errVal) + self.theText = "# {0}\n\n{1}\n\n".format(self.tr("ERROR"), errVal) self.errData.append(errVal) self.isNone = self.theItem.itemLayout == nwItemLayout.NO_LAYOUT @@ -318,7 +319,7 @@ class Tokenizer(): if len(self.theProject.autoReplace) > 0: repDict = {} for aKey, aVal in self.theProject.autoReplace.items(): - repDict["<%s>" % aKey] = aVal + repDict[f"<{aKey}>"] = aVal xRep = re.compile("|".join([re.escape(k) for k in repDict.keys()]), flags=re.DOTALL) self.theText = xRep.sub(lambda x: repDict[x.group(0)], self.theText) diff --git a/novelwriter/core/tomd.py b/novelwriter/core/tomd.py index 3ac0f58f..ff982868 100644 --- a/novelwriter/core/tomd.py +++ b/novelwriter/core/tomd.py @@ -100,33 +100,33 @@ class ToMarkdown(Tokenizer): # Process Text Type if tType == self.T_EMPTY: if len(thisPar) > 0: - tTemp = " \n".join(thisPar) - tmpResult.append("%s\n\n" % tTemp.rstrip(" ")) + tTemp = (" \n".join(thisPar)).rstrip(" ") + tmpResult.append(f"{tTemp}\n\n") thisPar = [] elif tType == self.T_TITLE: tHead = tText.replace(r"\\", "\n") - tmpResult.append("# %s\n\n" % tHead) + tmpResult.append(f"# {tHead}\n\n") elif tType == self.T_UNNUM: tHead = tText.replace(r"\\", "\n") - tmpResult.append("## %s\n\n" % tHead) + tmpResult.append(f"## {tHead}\n\n") elif tType == self.T_HEAD1: tHead = tText.replace(r"\\", "\n") - tmpResult.append("# %s\n\n" % tHead) + tmpResult.append(f"# {tHead}\n\n") elif tType == self.T_HEAD2: tHead = tText.replace(r"\\", "\n") - tmpResult.append("## %s\n\n" % tHead) + tmpResult.append(f"## {tHead}\n\n") elif tType == self.T_HEAD3: tHead = tText.replace(r"\\", "\n") - tmpResult.append("### %s\n\n" % tHead) + tmpResult.append(f"### {tHead}\n\n") elif tType == self.T_HEAD4: tHead = tText.replace(r"\\", "\n") - tmpResult.append("#### %s\n\n" % tHead) + tmpResult.append(f"#### {tHead}\n\n") elif tType == self.T_SEP: tmpResult.append("%s\n\n" % tText) @@ -141,10 +141,12 @@ class ToMarkdown(Tokenizer): thisPar.append(tTemp.rstrip()) elif tType == self.T_SYNOPSIS and self.doSynopsis: - tmpResult.append("**%s:** %s\n\n" % (self._localLookup("Synopsis"), tText)) + locName = self._localLookup("Synopsis") + tmpResult.append(f"**{locName}:** {tText}\n\n") elif tType == self.T_COMMENT and self.doComments: - tmpResult.append("**%s:** %s\n\n" % (self._localLookup("Comment"), tText)) + locName = self._localLookup("Comment") + tmpResult.append(f"**{locName}:** {tText}\n\n") elif tType == self.T_KEYWORD and self.doKeywords: tmpResult.append(self._formatKeywords(tText, tStyle)) @@ -189,7 +191,7 @@ class ToMarkdown(Tokenizer): retText = "" if theBits[0] in nwLabels.KEY_NAME: - retText += "**%s:** " % nwLabels.KEY_NAME[theBits[0]] + retText += f"**{nwLabels.KEY_NAME[theBits[0]]}:** " if len(theBits) > 1: retText += ", ".join(theBits[1:]) diff --git a/novelwriter/core/toodt.py b/novelwriter/core/toodt.py index 3529855d..4db138e7 100644 --- a/novelwriter/core/toodt.py +++ b/novelwriter/core/toodt.py @@ -45,18 +45,35 @@ XML_NS = { "meta": "urn:oasis:names:tc:opendocument:xmlns:meta:1.0", "fo": "urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0", } +MANI_NS = { + "manifest": "urn:oasis:names:tc:opendocument:xmlns:manifest:1.0" +} +OFFICE_NS = { + "office": XML_NS["office"] +} + + +def _mkTag(nsName, tagName, nsMap=XML_NS): + """Assemble namespace and tag name. + """ + theNS = nsMap.get(nsName, "") + if theNS: + return f"{{{theNS}}}{tagName}" + logger.warning("Missing xml namespace '%s'", nsName) + return tagName + # Mimetype and Version X_MIME = "application/vnd.oasis.opendocument.text" X_VERS = "1.2" # Text Formatting Tags -TAG_BR = "{%s}line-break" % XML_NS["text"] -TAG_SPC = "{%s}s" % XML_NS["text"] -TAG_NSPC = "{%s}c" % XML_NS["text"] -TAG_TAB = "{%s}tab" % XML_NS["text"] -TAG_SPAN = "{%s}span" % XML_NS["text"] -TAG_STNM = "{%s}style-name" % XML_NS["text"] +TAG_BR = _mkTag("text", "line-break") +TAG_SPC = _mkTag("text", "s") +TAG_NSPC = _mkTag("text", "c") +TAG_TAB = _mkTag("text", "tab") +TAG_SPAN = _mkTag("text", "span") +TAG_STNM = _mkTag("text", "style-name") # Formatting Codes X_BLD = 0x01 # Bold format @@ -313,7 +330,7 @@ class ToOdt(Tokenizer): # Meta Data xMeta = etree.SubElement(self._xMeta, _mkTag("meta", "creation-date")) - xMeta.text = datetime.now().strftime("%Y-%m-%dT%H:%M:%S") + xMeta.text = datetime.now().strftime(r"%Y-%m-%dT%H:%M:%S") xMeta = etree.SubElement(self._xMeta, _mkTag("meta", "generator")) xMeta.text = f"novelWriter/{novelwriter.__version__}" @@ -472,24 +489,22 @@ class ToOdt(Tokenizer): def saveOpenDocText(self, savePath): """Save the data to an .odt file. """ - mMap = {"manifest": "urn:oasis:names:tc:opendocument:xmlns:manifest:1.0"} - mMani = "{%s}manifest" % mMap["manifest"] - mVers = "{%s}version" % mMap["manifest"] - mPath = "{%s}full-path" % mMap["manifest"] - mType = "{%s}media-type" % mMap["manifest"] - mFile = "{%s}file-entry" % mMap["manifest"] + mMani = _mkTag("manifest", "manifest", nsMap=MANI_NS) + mVers = _mkTag("manifest", "version", nsMap=MANI_NS) + mPath = _mkTag("manifest", "full-path", nsMap=MANI_NS) + mType = _mkTag("manifest", "media-type", nsMap=MANI_NS) + mFile = _mkTag("manifest", "file-entry", nsMap=MANI_NS) - xMani = etree.Element(mMani, attrib={mVers: X_VERS}, nsmap=mMap) + xMani = etree.Element(mMani, attrib={mVers: X_VERS}, nsmap=MANI_NS) etree.SubElement(xMani, mFile, attrib={mPath: "/", mVers: X_VERS, mType: X_MIME}) etree.SubElement(xMani, mFile, attrib={mPath: "settings.xml", mType: "text/xml"}) etree.SubElement(xMani, mFile, attrib={mPath: "content.xml", mType: "text/xml"}) etree.SubElement(xMani, mFile, attrib={mPath: "meta.xml", mType: "text/xml"}) etree.SubElement(xMani, mFile, attrib={mPath: "styles.xml", mType: "text/xml"}) - sMap = {"office": "urn:oasis:names:tc:opendocument:xmlns:office:1.0"} - oRoot = "{%s}document-settings" % sMap["office"] - oSett = "{%s}settings" % sMap["office"] - xSett = etree.Element(oRoot, nsmap=sMap) + oRoot = _mkTag("office", "document-settings", nsMap=OFFICE_NS) + oSett = _mkTag("office", "settings", nsMap=OFFICE_NS) + xSett = etree.Element(oRoot, nsmap=OFFICE_NS) etree.SubElement(xSett, oSett) with ZipFile(savePath, mode="w") as outFile: @@ -520,16 +535,16 @@ class ToOdt(Tokenizer): """Apply formatting to synopsis lines. """ sSynop = self._localLookup("Synopsis") - rTxt = "**%s:** %s" % (sSynop, tText) - rFmt = "_B%s b_ %s" % (" "*len(sSynop), " "*len(tText)) + rTxt = "**{0}:** {1}".format(sSynop, tText) + rFmt = "_B{0} b_ {1}".format(" "*len(sSynop), " "*len(tText)) return rTxt, rFmt def _formatComments(self, tText): """Apply formatting to comments. """ sComm = self._localLookup("Comment") - rTxt = "**%s:** %s" % (sComm, tText) - rFmt = "_B%s b_ %s" % (" "*len(sComm), " "*len(tText)) + rTxt = "**{0}:** {1}".format(sComm, tText) + rFmt = "_B{0} b_ {1}".format(" "*len(sComm), " "*len(tText)) return rTxt, rFmt def _formatKeywords(self, tText): @@ -543,8 +558,8 @@ class ToOdt(Tokenizer): rFmt = "" if theBits[0] in nwLabels.KEY_NAME: tText = nwLabels.KEY_NAME[theBits[0]] - rTxt += "**%s:** " % tText - rFmt += "_B%s b_ " % (" "*len(tText)) + rTxt += "**{0}:** ".format(tText) + rFmt += "_B{0} b_ ".format(" "*len(tText)) if len(theBits) > 1: if theBits[0] == nwKeyWords.TAG_KEY: rTxt += theBits[1] @@ -1468,16 +1483,4 @@ class XMLParagraph(): return - -# =============================================================================================== # -# Local Functions -# =============================================================================================== # - -def _mkTag(nsName, tagName): - """Assemble namespace and tag name. - """ - theNS = XML_NS.get(nsName, "") - if theNS: - return "{%s}%s" % (theNS, tagName) - logger.warning("Missing xml namespace '%s'", nsName) - return tagName +# END Class XMLParagraph diff --git a/novelwriter/core/tree.py b/novelwriter/core/tree.py index 5b6fe6d1..04b754aa 100644 --- a/novelwriter/core/tree.py +++ b/novelwriter/core/tree.py @@ -158,7 +158,7 @@ class NWTree(): continue tFile = tHandle+".nwd" if os.path.isfile(os.path.join(self.theProject.projContent, tFile)): - tocLine = "%-25s %-9s %-8s %s" % ( + tocLine = "{0:<25s} {1:<9s} {2:<8s} {3:s}".format( os.path.join("content", tFile), tItem.itemClass.name, tItem.itemLayout.name, @@ -175,7 +175,7 @@ class NWTree(): outFile.write("Table of Contents\n") outFile.write("=================\n") outFile.write("\n") - outFile.write("%-25s %-9s %-8s %s\n" % ( + outFile.write("{0:<25s} {1:<9s} {2:<8s} {3:s}\n".format( "File Name", "Class", "Layout", "Document Label" )) outFile.write("-"*max(tocLen, 62) + "\n") @@ -285,12 +285,12 @@ class NWTree(): tItem = self.__getitem__(tHandle) if tItem is not None: tTree.append(tHandle) - for i in range(nwConst.MAX_DEPTH + 1): + for _ in range(nwConst.MAX_DEPTH + 1): if tItem.itemParent is None: return tTree else: tHandle = tItem.itemParent - tItem = self.__getitem__(tHandle) + tItem = self.__getitem__(tHandle) if tItem is None: return tTree else: @@ -341,7 +341,7 @@ class NWTree(): if tItem is None: return False if tItem.itemType != nwItemType.FILE: - logger.error("Item %s is not a file", tHandle) + logger.error("Item '%s' is not a file", tHandle) return False if not isinstance(itemLayout, nwItemLayout): return False diff --git a/novelwriter/dialogs/about.py b/novelwriter/dialogs/about.py index 2aefc3f6..d9f89f3c 100644 --- a/novelwriter/dialogs/about.py +++ b/novelwriter/dialogs/about.py @@ -65,7 +65,7 @@ class GuiAbout(QDialog): self.nwIcon = QLabel() self.nwIcon.setPixmap(self.theParent.theTheme.getPixmap("novelwriter", (nPx, nPx))) self.lblName = QLabel("novelWriter") - self.lblVers = QLabel("v%s" % novelwriter.__version__) + self.lblVers = QLabel(f"v{novelwriter.__version__}") self.lblDate = QLabel(datetime.strptime(novelwriter.__date__, "%Y-%m-%d").strftime("%x")) self.leftBox = QVBoxLayout() @@ -178,7 +178,7 @@ class GuiAbout(QDialog): ), ) - aboutMsg += "

%s

%s

" % ( + aboutMsg += "

{0}

{1}

".format( self.tr("Translations"), self._wrapTable([ ("English", "Veronica Berglyd Olsen"), @@ -193,7 +193,7 @@ class GuiAbout(QDialog): theIcons = self.theParent.theTheme.theIcons if theTheme.themeName and theTheme.themeAuthor != "N/A": licURL = f"{theTheme.themeLicense}" - aboutMsg += "

%s

%s

" % ( + aboutMsg += "

{0}

{1}

".format( self.tr("Theme: {0}").format(theTheme.themeName), self._wrapTable([ (self.tr("Author"), theTheme.themeAuthor), @@ -204,7 +204,7 @@ class GuiAbout(QDialog): if theIcons.themeName: licURL = f"{theIcons.themeLicense}" - aboutMsg += "

%s

%s

" % ( + aboutMsg += "

{0}

{1}

".format( self.tr("Icons: {0}").format(theIcons.themeName), self._wrapTable([ (self.tr("Author"), theIcons.themeAuthor), @@ -215,7 +215,7 @@ class GuiAbout(QDialog): if theTheme.syntaxName: licURL = f"{theTheme.syntaxLicense}" - aboutMsg += "

%s

%s

" % ( + aboutMsg += "

{0}

{1}

".format( self.tr("Syntax: {0}").format(theTheme.syntaxName), self._wrapTable([ (self.tr("Author"), theTheme.syntaxAuthor), @@ -258,7 +258,7 @@ class GuiAbout(QDialog): theTable.append( f"{aLabel}:{aValue}" ) - return "%s
" % "".join(theTable) + return "{0}
".format("".join(theTable)) def _setStyleSheet(self): """Set stylesheet for all browser tabs diff --git a/novelwriter/dialogs/docmerge.py b/novelwriter/dialogs/docmerge.py index e73e44fa..5b20c1e5 100644 --- a/novelwriter/dialogs/docmerge.py +++ b/novelwriter/dialogs/docmerge.py @@ -55,7 +55,7 @@ class GuiDocMerge(QDialog): self.outerBox = QVBoxLayout() self.setWindowTitle(self.tr("Merge Documents")) - self.headLabel = QLabel("%s" % self.tr("Documents to Merge")) + self.headLabel = QLabel("{0}".format(self.tr("Documents to Merge"))) self.helpLabel = QHelpLabel( self.tr("Drag and drop items to change the order."), self.theParent.theTheme.helpText ) diff --git a/novelwriter/dialogs/docsplit.py b/novelwriter/dialogs/docsplit.py index ed98fdd1..8d6002d6 100644 --- a/novelwriter/dialogs/docsplit.py +++ b/novelwriter/dialogs/docsplit.py @@ -59,7 +59,7 @@ class GuiDocSplit(QDialog): self.outerBox = QVBoxLayout() self.setWindowTitle(self.tr("Split Document")) - self.headLabel = QLabel("%s" % self.tr("Document Headers")) + self.headLabel = QLabel("{0}".format(self.tr("Document Headers"))) self.helpLabel = QHelpLabel( self.tr("Select the maximum level to split into files."), self.theParent.theTheme.helpText @@ -174,7 +174,7 @@ class GuiDocSplit(QDialog): msgYes = self.theParent.askQuestion( self.tr("Split Document"), - "%s

%s" % ( + "{0}

{1}".format( self.tr( "The document will be split into {0} file(s) in a new folder. " "The original document will remain intact.").format(nFiles), @@ -196,10 +196,8 @@ class GuiDocSplit(QDialog): # Loop through, and create the files for wTitle, iStart, iEnd in finalOrder: - if srcItem.itemClass == nwItemClass.NOVEL: - itemLayout = nwItemLayout.DOCUMENT - else: - itemLayout = nwItemLayout.NOTE + isNovel = srcItem.itemClass == nwItemClass.NOVEL + itemLayout = nwItemLayout.DOCUMENT if isNovel else nwItemLayout.NOTE wTitle = wTitle.lstrip("#") wTitle = wTitle.strip() @@ -209,9 +207,8 @@ class GuiDocSplit(QDialog): newItem.setLayout(itemLayout) newItem.setStatus(srcItem.itemStatus) logger.verbose( - "Creating new document %s with text from line %d to %d" % ( - nHandle, iStart+1, iEnd - ) + "Creating new document '%s' with text from line %d to %d", + nHandle, iStart+1, iEnd ) theText = "\n".join(self.sourceText[iStart:iEnd]) @@ -273,7 +270,8 @@ class GuiDocSplit(QDialog): spLevel = self.splitLevel.currentData() self.optState.setValue("GuiDocSplit", "spLevel", spLevel) logger.debug( - "Scanning document %s for headings level <= %d" % (self.sourceItem, spLevel) + "Scanning document '%s' for headings level <= %d", + self.sourceItem, spLevel ) self.sourceText = theText.splitlines() diff --git a/novelwriter/dialogs/itemeditor.py b/novelwriter/dialogs/itemeditor.py index a49bcdca..784b8f69 100644 --- a/novelwriter/dialogs/itemeditor.py +++ b/novelwriter/dialogs/itemeditor.py @@ -57,7 +57,8 @@ class GuiItemEditor(QDialog): self.theItem = self.theProject.projTree[tHandle] if self.theItem is None: - self._doClose() + self.close() + return self.setWindowTitle(self.tr("Item Settings")) diff --git a/novelwriter/gui/doceditor.py b/novelwriter/gui/doceditor.py index b23a8aaf..9db0ca15 100644 --- a/novelwriter/gui/doceditor.py +++ b/novelwriter/gui/doceditor.py @@ -719,7 +719,7 @@ class GuiDocEditor(QTextEdit): ), nwAlert.INFO) theMode = False - if self.spEnchant.spellLanguage is None: + if self.spEnchant.spellLanguage() is None: theMode = False self._spellCheck = theMode diff --git a/novelwriter/guimain.py b/novelwriter/guimain.py index 1b32718f..ad25f5fe 100644 --- a/novelwriter/guimain.py +++ b/novelwriter/guimain.py @@ -784,7 +784,7 @@ class GuiMain(QMainWindow): def passDocumentAction(self, theAction): """Pass on document action to the document viewer if it has focus, or pass it to the document editor if it or any of - its clid widgets have focus. If neither has focus, ignore the + its child widgets have focus. If neither has focus, ignore the action. """ if self.docViewer.hasFocus(): @@ -836,13 +836,13 @@ class GuiMain(QMainWindow): if tHandle is None: logger.warning("No item selected") - return + return False tItem = self.theProject.projTree[tHandle] if tItem is None: - return + return False if tItem.itemType not in nwLists.REG_TYPES: - return + return False logger.verbose("Requesting change to item '%s'", tHandle) dlgProj = GuiItemEditor(self, tHandle) @@ -853,7 +853,7 @@ class GuiMain(QMainWindow): self.docEditor.updateDocInfo(tHandle) self.docViewer.updateDocInfo(tHandle) - return + return True def rebuildTrees(self): """Rebuild the project tree. @@ -938,7 +938,7 @@ class GuiMain(QMainWindow): ## def showProjectLoadDialog(self): - """Opens the projects dialog for selecting either existing + """Open the projects dialog for selecting either existing projects from a cache of recently opened projects, or provide a browse button for projects not yet cached. Selecting to create a new project is forwarded to the new project wizard. @@ -1058,7 +1058,7 @@ class GuiMain(QMainWindow): return def showWritingStatsDialog(self): - """Open the session log dialog. + """Open the session stats dialog. """ if not self.hasProject: logger.error("No project open") @@ -1092,17 +1092,17 @@ class GuiMain(QMainWindow): if showNotes: dlgAbout.showReleaseNotes() - return + return True def showAboutQtDialog(self): """Show the about dialog for Qt. """ msgBox = QMessageBox() msgBox.aboutQt(self, "About Qt") - return + return True def showUpdatesDialog(self): - """Show the updates dialog for novelWriter. + """Show the check for updates dialog. """ dlgUpdate = getGuiItem("GuiUpdates") if dlgUpdate is None: @@ -1117,11 +1117,11 @@ class GuiMain(QMainWindow): return def makeAlert(self, theMessage, theLevel=nwAlert.INFO): - """Alert both the user and the logger at the same time. Message - can be either a string or an array of strings. + """Alert both the user and the logger at the same time. The + message can be either a string or a list of strings. """ if isinstance(theMessage, list): - theMessage = list(filter(None, theMessage)) + theMessage = list(filter(None, theMessage)) # Strip empty strings popMsg = "
".join(theMessage) logMsg = theMessage else: @@ -1246,13 +1246,11 @@ class GuiMain(QMainWindow): self.theProject.setLastViewed(None) bPos = self.splitMain.sizes() self.splitView.setVisible(False) - vPos = [bPos[1], 0] - self.splitDocs.setSizes(vPos) + self.splitDocs.setSizes([bPos[1], 0]) return not self.splitView.isVisible() def toggleFocusMode(self): - """Main GUI Focus Mode hides tree, view pane and optionally also - statusbar and menu. + """Main GUI Focus Mode hides tree, view, statusbar and menu. """ if self.docEditor.docHandle() is None: logger.error("No document open, so not activating Focus Mode") @@ -1409,7 +1407,7 @@ class GuiMain(QMainWindow): return True def _autoSaveProject(self): - """Triggered by the auto-save project timer to save the project. + """Triggered by the autosave project timer to save the project. """ doSave = self.hasProject doSave &= self.theProject.projChanged @@ -1422,7 +1420,7 @@ class GuiMain(QMainWindow): return def _autoSaveDocument(self): - """Triggered by the auto-save document timer to save the + """Triggered by the autosave document timer to save the document. """ if self.hasProject and self.docEditor.docChanged(): @@ -1511,7 +1509,7 @@ class GuiMain(QMainWindow): @pyqtSlot() def _timeTick(self): - """Triggered on every tick of the timer. + """Triggered on every tick of the main timer. """ if not self.hasProject: return diff --git a/tests/reference/guiItemEditor_Dialog_nwProject.nwx b/tests/reference/guiItemEditor_Dialog_nwProject.nwx deleted file mode 100644 index 17f65bcd..00000000 --- a/tests/reference/guiItemEditor_Dialog_nwProject.nwx +++ /dev/null @@ -1,115 +0,0 @@ - - - - New Project - - 2 - 1 - 0 - - - True - None - False - None - True - 0e17daca5f3e1 - None - 6 - 6 - 0 - - - %title% - %title% - %title% - * * * -
-
- - New - Note - Draft - Finished - - - New - Minor - Major - Main - -
- - - Novel - ROOT - NOVEL - New - True - - - Title Page - FILE - NOVEL - New - True - DOCUMENT - 11 - 2 - 0 - 0 - - - New Chapter - FOLDER - NOVEL - New - True - - - New Chapter - FILE - NOVEL - New - True - DOCUMENT - 11 - 2 - 0 - 0 - - - Just a Page - FILE - NOVEL - Note - False - DOCUMENT - 9 - 2 - 0 - 0 - - - Plot - ROOT - PLOT - New - False - - - Characters - ROOT - CHARACTER - New - False - - - World - ROOT - WORLD - New - False - - -
diff --git a/tests/test_base/test_base_common.py b/tests/test_base/test_base_common.py index 25527513..70fe4c72 100644 --- a/tests/test_base/test_base_common.py +++ b/tests/test_base/test_base_common.py @@ -31,8 +31,8 @@ from tools import writeFile from novelwriter.guimain import GuiMain from novelwriter.common import ( - checkString, checkInt, checkBool, checkHandle, isHandle, isTitleTag, - isItemClass, isItemType, isItemLayout, hexToInt, formatInt, + checkString, checkInt, checkFloat, checkBool, checkHandle, isHandle, + isTitleTag, isItemClass, isItemType, isItemLayout, hexToInt, formatInt, formatTimeStamp, formatTime, parseTimeStamp, splitVersionNumber, transferCase, fuzzyTime, numberToRoman, jsonEncode, readTextFile, makeFileNameSafe, sha256sum, getGuiItem, NWConfigParser @@ -68,6 +68,20 @@ def testBaseCommon_CheckInt(): # END Test testBaseCommon_CheckInt +@pytest.mark.base +def testBaseCommon_CheckFloat(): + """Test the checkFloat function. + """ + assert checkFloat(None, 3.0, True) is None + assert checkFloat("None", 3.0, True) is None + assert checkFloat(None, 3.0, False) == 3.0 + assert checkFloat(1, 3.0, False) == 1.0 + assert checkFloat(1.0, 3.0, False) == 1.0 + assert checkFloat(True, 3.0, False) == 1.0 + +# END Test testBaseCommon_CheckInt + + @pytest.mark.base def testBaseCommon_CheckBool(): """Test the checkBool function. diff --git a/tests/test_core/test_core_options.py b/tests/test_core/test_core_options.py index 41e0933d..7ef1b0ec 100644 --- a/tests/test_core/test_core_options.py +++ b/tests/test_core/test_core_options.py @@ -73,7 +73,7 @@ def testCoreOptions_LoadSave(monkeypatch, mockGUI, tmpDir): assert theOpts.loadSettings() # Check that unwanted items have been removed - assert theOpts.theState == { + assert theOpts._theState == { "GuiBuildNovel": { "winWidth": 1000, "winHeight": 700, @@ -88,7 +88,7 @@ def testCoreOptions_LoadSave(monkeypatch, mockGUI, tmpDir): # Load again to check we get the values back assert theOpts.loadSettings() - assert theOpts.theState == { + assert theOpts._theState == { "GuiBuildNovel": { "winWidth": 1000, "winHeight": 700, @@ -129,7 +129,7 @@ def testCoreOptions_SetGet(mockGUI): assert theOpts.getValue("GuiBuildNovel", "mockItem", None) is None # Get type-specific - assert theOpts.getString("GuiBuildNovel", "winWidth", None) == "100" + assert theOpts.getString("GuiBuildNovel", "winWidth", None) is None assert theOpts.getString("GuiBuildNovel", "mockItem", None) is None assert theOpts.getInt("GuiBuildNovel", "winWidth", None) == 100 assert theOpts.getInt("GuiBuildNovel", "textFont", None) is None diff --git a/tests/test_core/test_core_spell.py b/tests/test_core/test_core_spell.py index db8937df..7d0df7cc 100644 --- a/tests/test_core/test_core_spell.py +++ b/tests/test_core/test_core_spell.py @@ -61,7 +61,7 @@ def testCoreSpell_Enchant(monkeypatch, tmpDir): assert spChk._readProjectDictionary(None) is False assert spChk._readProjectDictionary(wList) is True - assert spChk.projectDict == wList + assert spChk._projectDict == wList # Cannot write to file with monkeypatch.context() as mp: diff --git a/tests/test_core/test_core_tohtml.py b/tests/test_core/test_core_tohtml.py index d0450147..d569d983 100644 --- a/tests/test_core/test_core_tohtml.py +++ b/tests/test_core/test_core_tohtml.py @@ -27,59 +27,6 @@ from tools import readFile from novelwriter.core import NWProject, NWIndex, ToHtml -@pytest.mark.core -def testCoreToHtml_Format(mockGUI): - """Test all the formatters for the ToHtml class. - """ - theProject = NWProject(mockGUI) - mockGUI.theIndex = NWIndex(theProject) - theHtml = ToHtml(theProject) - - # Export Mode - # =========== - - assert theHtml._formatSynopsis("synopsis text") == ( - "

Synopsis: synopsis text

\n" - ) - assert theHtml._formatComments("comment text") == ( - "

Comment: comment text

\n" - ) - - assert theHtml._formatKeywords("") == "" - assert theHtml._formatKeywords("tag: Jane") == ( - "Tag: Jane" - ) - assert theHtml._formatKeywords("char: Bod, Jane") == ( - "Characters: " - "Bod, " - "Jane" - ) - - # Preview Mode - # ============ - - theHtml.setPreview(True, True) - - assert theHtml._formatSynopsis("synopsis text") == ( - "

Synopsis: synopsis text

\n" - ) - assert theHtml._formatComments("comment text") == ( - "

comment text

\n" - ) - - assert theHtml._formatKeywords("") == "" - assert theHtml._formatKeywords("tag: Jane") == ( - "Tag: Jane" - ) - assert theHtml._formatKeywords("char: Bod, Jane") == ( - "Characters: " - "Bod, " - "Jane" - ) - -# END Test testCoreToHtml_Format - - @pytest.mark.core def testCoreToHtml_ConvertFormat(mockGUI): """Test the tokenizer and converter chain using the ToHtml class. @@ -433,7 +380,7 @@ def testCoreToHtml_ConvertDirect(mockGUI): @pytest.mark.core def testCoreToHtml_Complex(mockGUI, fncDir): - """Test the ave method of the ToHtml class. + """Test the save method of the ToHtml class. """ theProject = NWProject(mockGUI) theHtml = ToHtml(theProject) @@ -524,7 +471,7 @@ def testCoreToHtml_Complex(mockGUI, fncDir): theHtml.saveHTML5(saveFile) assert readFile(saveFile) == htmlDoc -# END Test testCoreToHtml_Save +# END Test testCoreToHtml_Complex @pytest.mark.core @@ -589,3 +536,56 @@ def testCoreToHtml_Methods(mockGUI): assert theHtml.getStyleSheet() == [] # END Test testCoreToHtml_Methods + + +@pytest.mark.core +def testCoreToHtml_Format(mockGUI): + """Test all the formatters for the ToHtml class. + """ + theProject = NWProject(mockGUI) + mockGUI.theIndex = NWIndex(theProject) + theHtml = ToHtml(theProject) + + # Export Mode + # =========== + + assert theHtml._formatSynopsis("synopsis text") == ( + "

Synopsis: synopsis text

\n" + ) + assert theHtml._formatComments("comment text") == ( + "

Comment: comment text

\n" + ) + + assert theHtml._formatKeywords("") == "" + assert theHtml._formatKeywords("tag: Jane") == ( + "Tag: Jane" + ) + assert theHtml._formatKeywords("char: Bod, Jane") == ( + "Characters: " + "Bod, " + "Jane" + ) + + # Preview Mode + # ============ + + theHtml.setPreview(True, True) + + assert theHtml._formatSynopsis("synopsis text") == ( + "

Synopsis: synopsis text

\n" + ) + assert theHtml._formatComments("comment text") == ( + "

comment text

\n" + ) + + assert theHtml._formatKeywords("") == "" + assert theHtml._formatKeywords("tag: Jane") == ( + "Tag: Jane" + ) + assert theHtml._formatKeywords("char: Bod, Jane") == ( + "Characters: " + "Bod, " + "Jane" + ) + +# END Test testCoreToHtml_Format diff --git a/tests/test_core/test_core_tomd.py b/tests/test_core/test_core_tomd.py new file mode 100644 index 00000000..267cce92 --- /dev/null +++ b/tests/test_core/test_core_tomd.py @@ -0,0 +1,277 @@ +""" +novelWriter – ToMd Class Tester +=============================== + +This file is a part of novelWriter +Copyright 2018–2021, 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 pytest + +from tools import readFile + +from novelwriter.core import NWProject, NWIndex, ToMarkdown + + +@pytest.mark.core +def testCoreToMarkdown_ConvertFormat(mockGUI): + """Test the tokenizer and converter chain using the ToMarkdown class. + """ + theProject = NWProject(mockGUI) + mockGUI.theIndex = NWIndex(theProject) + theMD = ToMarkdown(theProject) + + # Headers + # ======= + + theMD.isNovel = True + theMD.isNote = False + theMD.isFirst = True + + # Header 1 + theMD.theText = "# Partition\n" + theMD.tokenizeText() + theMD.doConvert() + assert theMD.theResult == "# Partition\n\n" + + # Header 2 + theMD.theText = "## Chapter Title\n" + theMD.tokenizeText() + theMD.doConvert() + assert theMD.theResult == "## Chapter Title\n\n" + + # Header 3 + theMD.theText = "### Scene Title\n" + theMD.tokenizeText() + theMD.doConvert() + assert theMD.theResult == "### Scene Title\n\n" + + # Header 4 + theMD.theText = "#### Section Title\n" + theMD.tokenizeText() + theMD.doConvert() + assert theMD.theResult == "#### Section Title\n\n" + + # Title + theMD.theText = "#! Title\n" + theMD.tokenizeText() + theMD.doConvert() + assert theMD.theResult == "# Title\n\n" + + # Unnumbered + theMD.theText = "##! Prologue\n" + theMD.tokenizeText() + theMD.doConvert() + assert theMD.theResult == "## Prologue\n\n" + + # Paragraphs + # ========== + + # Text for GitHub Markdown + theMD.setGitHubMarkdown() + theMD.theText = "Some **nested bold and _italic_ and ~~strikethrough~~ text** here\n" + theMD.tokenizeText() + theMD.doConvert() + assert theMD.theResult == ( + "Some **nested bold and _italic_ and ~~strikethrough~~ text** here\n\n" + ) + + # Text for Standard Markdown + theMD.setStandardMarkdown() + theMD.theText = "Some **nested bold and _italic_ and ~~strikethrough~~ text** here\n" + theMD.tokenizeText() + theMD.doConvert() + assert theMD.theResult == ( + "Some **nested bold and _italic_ and strikethrough text** here\n\n" + ) + + # Text w/Hard Break + theMD.theText = "Line one \nLine two \nLine three\n" + theMD.tokenizeText() + theMD.doConvert() + assert theMD.theResult == "Line one \nLine two \nLine three\n\n" + + # Synopsis + theMD.theText = "%synopsis: The synopsis ...\n" + theMD.tokenizeText() + theMD.doConvert() + assert theMD.theResult == "" + + theMD.setSynopsis(True) + theMD.theText = "%synopsis: The synopsis ...\n" + theMD.tokenizeText() + theMD.doConvert() + assert theMD.theResult == "**Synopsis:** The synopsis ...\n\n" + + # Comment + theMD.theText = "% A comment ...\n" + theMD.tokenizeText() + theMD.doConvert() + assert theMD.theResult == "" + + theMD.setComments(True) + theMD.theText = "% A comment ...\n" + theMD.tokenizeText() + theMD.doConvert() + assert theMD.theResult == "**Comment:** A comment ...\n\n" + + # Keywords + theMD.theText = "@char: Bod, Jane\n" + theMD.tokenizeText() + theMD.doConvert() + assert theMD.theResult == "" + + theMD.setKeywords(True) + theMD.theText = "@char: Bod, Jane\n" + theMD.tokenizeText() + theMD.doConvert() + assert theMD.theResult == "**Characters:** Bod, Jane\n\n" + + # Multiple Keywords + theMD.setKeywords(True) + theMD.theText = "## Chapter\n\n@pov: Bod\n@plot: Main\n@location: Europe\n\n" + theMD.tokenizeText() + theMD.doConvert() + assert theMD.theResult == ( + "## Chapter\n\n" + "**Point of View:** Bod \n" + "**Plot:** Main \n" + "**Locations:** Europe\n\n" + ) + +# END Test testCoreToMarkdown_ConvertFormat + + +@pytest.mark.core +def testCoreToMarkdown_ConvertDirect(mockGUI): + """Test the converter directly using the ToMarkdown class. + """ + theProject = NWProject(mockGUI) + mockGUI.theIndex = NWIndex(theProject) + theMD = ToMarkdown(theProject) + + theMD.isNovel = True + theMD.isNote = False + + # Special Titles + # ============== + + # Title + theMD.theTokens = [ + (theMD.T_TITLE, 1, "A Title", None, theMD.A_PBB | theMD.A_CENTRE), + (theMD.T_EMPTY, 1, "", None, theMD.A_NONE), + ] + theMD.doConvert() + assert theMD.theResult == "# A Title\n\n" + + # Unnumbered + theMD.theTokens = [ + (theMD.T_UNNUM, 1, "Prologue", None, theMD.A_PBB), + (theMD.T_EMPTY, 1, "", None, theMD.A_NONE), + ] + theMD.doConvert() + assert theMD.theResult == "## Prologue\n\n" + + # Separators + # ========== + + # Separator + theMD.theTokens = [ + (theMD.T_SEP, 1, "* * *", None, theMD.A_CENTRE), + (theMD.T_EMPTY, 1, "", None, theMD.A_NONE), + ] + theMD.doConvert() + assert theMD.theResult == "* * *\n\n" + + # Skip + theMD.theTokens = [ + (theMD.T_SKIP, 1, "", None, theMD.A_NONE), + (theMD.T_EMPTY, 1, "", None, theMD.A_NONE), + ] + theMD.doConvert() + assert theMD.theResult == "\n\n\n" + +# END Test testCoreToMarkdown_ConvertDirect + + +@pytest.mark.core +def testCoreToMarkdown_Complex(mockGUI, fncDir): + """Test the save method of the ToMarkdown class. + """ + theProject = NWProject(mockGUI) + theMD = ToMarkdown(theProject) + theMD.isNovel = True + + # Build Project + # ============= + + docText = [ + "# My Novel\n**By Jane Doh**\n", + "## Chapter 1\n\nThe text of chapter one.\n", + "### Scene 1\n\nThe text of scene one.\n", + "#### A Section\n\nMore text in scene one.\n", + "## Chapter 2\n\nThe text of chapter two.\n", + "### Scene 2\n\nThe text of scene two.\n", + "#### A Section\n\n\tMore text in scene two.\n", + ] + resText = [ + "# My Novel\n\n**By Jane Doh**\n\n", + "## Chapter 1\n\nThe text of chapter one.\n\n", + "### Scene 1\n\nThe text of scene one.\n\n", + "#### A Section\n\nMore text in scene one.\n\n", + "## Chapter 2\n\nThe text of chapter two.\n\n", + "### Scene 2\n\nThe text of scene two.\n\n", + "#### A Section\n\n\tMore text in scene two.\n\n", + ] + + for i in range(len(docText)): + theMD.theText = docText[i] + theMD.doPreProcessing() + theMD.tokenizeText() + theMD.doConvert() + assert theMD.theResult == resText[i] + + assert theMD.fullMD == resText + assert theMD.getFullResultSize() == len("".join(resText)) + + theMD.replaceTabs(nSpaces=4, spaceChar=" ") + resText[6] = "#### A Section\n\n More text in scene two.\n\n" + + # Check File + # ========== + + saveFile = os.path.join(fncDir, "outFile.md") + theMD.saveMarkdown(saveFile) + assert readFile(saveFile) == "".join(resText) + +# END Test testCoreToHtml_Complex + + +@pytest.mark.core +def testCoreToMarkdown_Format(mockGUI): + """Test all the formatters for the ToMarkdown class. + """ + theProject = NWProject(mockGUI) + mockGUI.theIndex = NWIndex(theProject) + theMD = ToMarkdown(theProject) + + assert theMD._formatKeywords("", theMD.A_NONE) == "" + assert theMD._formatKeywords("tag: Jane", theMD.A_NONE) == "**Tag:** Jane\n\n" + assert theMD._formatKeywords("tag: Jane, John", theMD.A_NONE) == "**Tag:** Jane, John\n\n" + assert theMD._formatKeywords("tag: Jane", theMD.A_Z_BTMMRG) == "**Tag:** Jane \n" + +# END Test testCoreToMarkdown_Format diff --git a/tests/test_core/test_core_toodt.py b/tests/test_core/test_core_toodt.py index ca3c0d8a..403309d1 100644 --- a/tests/test_core/test_core_toodt.py +++ b/tests/test_core/test_core_toodt.py @@ -559,6 +559,62 @@ def testCoreToOdt_Convert(mockGUI): # END Test testCoreToOdt_Convert +@pytest.mark.core +def testCoreToOdt_ConvertDirect(mockGUI): + """Test the converter directly using the ToOdt class to reach some + otherwise hard to reach conditions. + """ + theProject = NWProject(mockGUI) + mockGUI.theIndex = NWIndex(theProject) + theDoc = ToOdt(theProject, isFlat=True) + + theDoc.isNovel = True + + # Justified + theDoc = ToOdt(theProject, isFlat=True) + theDoc.theTokens = [ + (theDoc.T_TEXT, 1, "This is a paragraph", [], theDoc.A_JUSTIFY), + (theDoc.T_EMPTY, 1, "", None, theDoc.A_NONE), + ] + theDoc.initDocument() + theDoc.doConvert() + theDoc.closeDocument() + assert ( + '' + '' + '' + ) in xmlToText(theDoc._xAuto) + assert xmlToText(theDoc._xText) == ( + '' + 'This is a paragraph' + '' + ) + + # Page Break After + theDoc = ToOdt(theProject, isFlat=True) + theDoc.theTokens = [ + (theDoc.T_TEXT, 1, "This is a paragraph", [], theDoc.A_PBA), + (theDoc.T_EMPTY, 1, "", None, theDoc.A_NONE), + ] + theDoc.initDocument() + theDoc.doConvert() + theDoc.closeDocument() + assert ( + '' + '' + '' + ) in xmlToText(theDoc._xAuto) + assert xmlToText(theDoc._xText) == ( + '' + 'This is a paragraph' + '' + ) + +# END Test testCoreToOdt_ConvertDirect + + @pytest.mark.core def testCoreToOdt_SaveFlat(mockGUI, fncDir, outDir, refDir): """Test the document save functions. @@ -676,6 +732,36 @@ def testCoreToOdt_SaveFull(mockGUI, fncDir, outDir, refDir): # END Test testCoreToOdt_SaveFull +@pytest.mark.core +def testCoreToOdt_Format(mockGUI): + """Test the formatters for the ToOdt class. + """ + theProject = NWProject(mockGUI) + mockGUI.theIndex = NWIndex(theProject) + theDoc = ToOdt(theProject, isFlat=True) + + assert theDoc._formatSynopsis("synopsis text") == ( + "**Synopsis:** synopsis text", + "_B b_ " + ) + assert theDoc._formatComments("comment text") == ( + "**Comment:** comment text", + "_B b_ " + ) + + assert theDoc._formatKeywords("") == "" + assert theDoc._formatKeywords("tag: Jane") == ( + "**Tag:** Jane", + "_B b_ " + ) + assert theDoc._formatKeywords("char: Bod, Jane") == ( + "**Characters:** Bod, Jane", + "_B b_ " + ) + +# END Test testCoreToOdt_Format + + @pytest.mark.core def testCoreToOdt_ODTParagraphStyle(): """Test the ODTParagraphStyle class. diff --git a/tests/test_dialogs/test_dlg_about.py b/tests/test_dialogs/test_dlg_about.py index 76f66984..2ce05223 100644 --- a/tests/test_dialogs/test_dlg_about.py +++ b/tests/test_dialogs/test_dlg_about.py @@ -27,23 +27,22 @@ from PyQt5.QtWidgets import QAction, QMessageBox from novelwriter.dialogs import GuiAbout -keyDelay = 2 -typeDelay = 1 -stepDelay = 20 - @pytest.mark.gui -def testDlgAbout_Dialog(qtbot, monkeypatch, nwGUI): - """Test the full about dialogs. +def testDlgAbout_NWDialog(qtbot, monkeypatch, nwGUI): + """Test the novelWriter about dialogs. """ - # NW About - monkeypatch.setattr(GuiAbout, "exec_", lambda *a: None) - nwGUI.mainMenu.aAboutNW.activate(QAction.Trigger) - qtbot.waitUntil(lambda: getGuiItem("GuiAbout") is not None, timeout=1000) + # Block message box + monkeypatch.setattr(QMessageBox, "question", lambda *a: QMessageBox.Yes) + # NW About + nwGUI.theTheme.themeName = "A Theme" + nwGUI.theTheme.themeAuthor = "An Author" + assert nwGUI.showAboutNWDialog(showNotes=True) is True + + qtbot.waitUntil(lambda: getGuiItem("GuiAbout") is not None, timeout=1000) msgAbout = getGuiItem("GuiAbout") assert isinstance(msgAbout, GuiAbout) - msgAbout.show() assert msgAbout.pageAbout.document().characterCount() > 100 assert msgAbout.pageNotes.document().characterCount() > 100 @@ -59,12 +58,29 @@ def testDlgAbout_Dialog(qtbot, monkeypatch, nwGUI): msgAbout.showReleaseNotes() assert msgAbout.tabBox.currentWidget() == msgAbout.pageNotes - - # Qt About - monkeypatch.setattr(QMessageBox, "aboutQt", lambda *a, **k: None) - nwGUI.mainMenu.aAboutQt.activate(QAction.Trigger) - - # qtbot.stopForInteraction() msgAbout._doClose() -# END Test testDlgAbout_Dialog + # Open Again from Menu + nwGUI.mainMenu.aAboutNW.activate(QAction.Trigger) + qtbot.waitUntil(lambda: getGuiItem("GuiAbout") is not None, timeout=1000) + msgAbout = getGuiItem("GuiAbout") + assert msgAbout is not None + msgAbout._doClose() + +# END Test testDlgAbout_NWDialog + + +@pytest.mark.gui +def testDlgAbout_QtDialog(monkeypatch, nwGUI): + """Test the Qt about dialogs. + """ + # Block message box + monkeypatch.setattr(QMessageBox, "question", lambda *a: QMessageBox.Yes) + monkeypatch.setattr(QMessageBox, "aboutQt", lambda *a, **k: None) + + # Open About + # All it can do is check aigainst a crash + assert nwGUI.showAboutQtDialog() is True + nwGUI.mainMenu.aAboutQt.activate(QAction.Trigger) + +# END Test testDlgAbout_QtDialog diff --git a/tests/test_dialogs/test_dlg_merge.py b/tests/test_dialogs/test_dlg_docmerge.py similarity index 98% rename from tests/test_dialogs/test_dlg_merge.py rename to tests/test_dialogs/test_dlg_docmerge.py index 17f566e9..f51aee3e 100644 --- a/tests/test_dialogs/test_dlg_merge.py +++ b/tests/test_dialogs/test_dlg_docmerge.py @@ -31,10 +31,6 @@ from novelwriter.dialogs import GuiDocMerge, GuiItemEditor from novelwriter.enum import nwItemType, nwWidget from novelwriter.core.tree import NWTree -keyDelay = 2 -typeDelay = 1 -stepDelay = 20 - @pytest.mark.gui def testDlgMerge_Main(qtbot, monkeypatch, nwGUI, fncProj): @@ -96,7 +92,7 @@ def testDlgMerge_Main(qtbot, monkeypatch, nwGUI, fncProj): nwMerge = getGuiItem("GuiDocMerge") assert isinstance(nwMerge, GuiDocMerge) nwMerge.show() - qtbot.wait(stepDelay) + qtbot.wait(50) # Populate List # ============= diff --git a/tests/test_dialogs/test_dlg_split.py b/tests/test_dialogs/test_dlg_docsplit.py similarity index 99% rename from tests/test_dialogs/test_dlg_split.py rename to tests/test_dialogs/test_dlg_docsplit.py index 32a9d138..c5bc9f3e 100644 --- a/tests/test_dialogs/test_dlg_split.py +++ b/tests/test_dialogs/test_dlg_docsplit.py @@ -32,10 +32,6 @@ from novelwriter.enum import nwItemType, nwWidget from novelwriter.core.document import NWDoc from novelwriter.core.tree import NWTree -keyDelay = 2 -typeDelay = 1 -stepDelay = 20 - @pytest.mark.gui def testDlgSplit_Main(qtbot, monkeypatch, nwGUI, fncProj): @@ -103,7 +99,7 @@ def testDlgSplit_Main(qtbot, monkeypatch, nwGUI, fncProj): nwSplit = getGuiItem("GuiDocSplit") assert isinstance(nwSplit, GuiDocSplit) nwSplit.show() - qtbot.wait(stepDelay) + qtbot.wait(50) # Populate List # ============= @@ -253,7 +249,7 @@ def testDlgSplit_Main(qtbot, monkeypatch, nwGUI, fncProj): nwSplit.sourceItem = None assert nwSplit._doSplit() is False - # Close up + # Close nwSplit._doClose() # qtbot.stopForInteraction() diff --git a/tests/test_dialogs/test_dlg_itemeditor.py b/tests/test_dialogs/test_dlg_itemeditor.py index 5c6d2277..d921238d 100644 --- a/tests/test_dialogs/test_dlg_itemeditor.py +++ b/tests/test_dialogs/test_dlg_itemeditor.py @@ -20,93 +20,213 @@ along with this program. If not, see . """ import pytest -import os -from shutil import copyfile -from tools import cmpFiles, getGuiItem +from tools import getGuiItem -from PyQt5.QtWidgets import QAction, QMessageBox +from PyQt5.QtWidgets import QAction, QDialog, QMessageBox from novelwriter.gui import GuiProjectTree -from novelwriter.enum import nwItemLayout +from novelwriter.enum import nwItemLayout, nwItemType from novelwriter.dialogs import GuiItemEditor - -keyDelay = 2 -typeDelay = 1 -stepDelay = 20 +from novelwriter.core.tree import NWTree @pytest.mark.gui -def testDlgItemEditor_Dialog(qtbot, monkeypatch, nwGUI, fncProj, refDir, outDir): - """Test the full item editor dialog. +def testDlgItemEditor_Dialog(qtbot, monkeypatch, nwGUI, fncProj): + """Test launching the item editor dialog from GuiMain. """ - projFile = os.path.join(fncProj, "nwProject.nwx") - testFile = os.path.join(outDir, "guiItemEditor_Dialog_nwProject.nwx") - compFile = os.path.join(refDir, "guiItemEditor_Dialog_nwProject.nwx") + # Block message box + monkeypatch.setattr(QMessageBox, "question", lambda *a: QMessageBox.Yes) + # Block Dialog exec_ + monkeypatch.setattr(GuiItemEditor, "exec_", lambda *a: None) + + # Open Editor wo/Project + assert nwGUI.editItem() is False + + # Create and Open Project + nwGUI.theProject.projTree.setSeed(42) + assert nwGUI.newProject({"projPath": fncProj}) + + # No Selection + nwGUI.treeView.clearSelection() + assert nwGUI.editItem() is False + + # Force opening from editor + assert nwGUI.openDocument("0e17daca5f3e1") + nwGUI.isFocusMode = True + + # Block Tree Lookup + with monkeypatch.context() as mp: + mp.setattr(NWTree, "__getitem__", lambda *a: None) + assert nwGUI.editItem() is False + + # Invalid Type + nwGUI.theProject.projTree["0e17daca5f3e1"].itemType = nwItemType.NO_TYPE + assert nwGUI.editItem() is False + nwGUI.theProject.projTree["0e17daca5f3e1"].itemType = nwItemType.FILE + + # Open Properly + assert nwGUI.editItem() is True + qtbot.waitUntil(lambda: getGuiItem("GuiItemEditor") is not None, timeout=1000) + itemEdit = getGuiItem("GuiItemEditor") + assert itemEdit is not None + itemEdit.close() + + # Open Via Menu + with monkeypatch.context() as mp: + mp.setattr(GuiItemEditor, "result", lambda *a: QDialog.Accepted) + nwGUI.mainMenu.aEditItem.activate(QAction.Trigger) + qtbot.waitUntil(lambda: getGuiItem("GuiItemEditor") is not None, timeout=1000) + itemEdit = getGuiItem("GuiItemEditor") + assert itemEdit is not None + itemEdit.close() + + nwGUI.isFocusMode = False + +# END Test testDlgItemEditor_Dialog + + +@pytest.mark.gui +def testDlgItemEditor_Novel(qtbot, monkeypatch, nwGUI, fncProj): + """Test the item editor dialog for a novel document. + """ # Block message box monkeypatch.setattr(QMessageBox, "question", lambda *a: QMessageBox.Yes) monkeypatch.setattr(GuiProjectTree, "hasFocus", lambda *a: True) - # Create new, save, open project + # Create Project and Open Document nwGUI.theProject.projTree.setSeed(42) assert nwGUI.newProject({"projPath": fncProj}) assert nwGUI.openDocument("0e17daca5f3e1") - assert nwGUI.treeView.setSelectedHandle("0e17daca5f3e1", doScroll=True) - monkeypatch.setattr(GuiItemEditor, "exec_", lambda *a: None) - nwGUI.mainMenu.aEditItem.activate(QAction.Trigger) - qtbot.waitUntil(lambda: getGuiItem("GuiItemEditor") is not None, timeout=1000) + # Check that an invalid handle is managed + itemEdit = GuiItemEditor(nwGUI, "whatever") + itemEdit.show() + itemEdit._doClose() - itemEdit = getGuiItem("GuiItemEditor") - assert isinstance(itemEdit, GuiItemEditor) + # Edit a Document + itemEdit = GuiItemEditor(nwGUI, "0e17daca5f3e1") itemEdit.show() - qtbot.addWidget(itemEdit) - + # Check Existing Settings assert itemEdit.editName.text() == "New Scene" assert itemEdit.editStatus.currentData() == "New" assert itemEdit.editLayout.currentData() == nwItemLayout.DOCUMENT + assert itemEdit.editExport.isChecked() is True - for c in "Just a Page": - qtbot.keyClick(itemEdit.editName, c, delay=typeDelay) + # Change Settings + layoutIdx = itemEdit.editLayout.findData(nwItemLayout.NOTE) + itemEdit.editName.setText("Great Scene") itemEdit.editStatus.setCurrentIndex(1) - layoutIdx = itemEdit.editLayout.findData(nwItemLayout.DOCUMENT) itemEdit.editLayout.setCurrentIndex(layoutIdx) - itemEdit.editExport.setChecked(False) - assert not itemEdit.editExport.isChecked() + + # Check New Settings itemEdit._doSave() + assert itemEdit.theItem.itemName == "Great Scene" + assert itemEdit.theItem.itemStatus == "Note" + assert itemEdit.theItem.itemLayout == nwItemLayout.NOTE + assert itemEdit.theItem.isExported is False - nwGUI.mainMenu.aEditItem.activate(QAction.Trigger) - qtbot.waitUntil(lambda: getGuiItem("GuiItemEditor") is not None, timeout=1000) - - itemEdit = getGuiItem("GuiItemEditor") - assert isinstance(itemEdit, GuiItemEditor) - itemEdit.show() - - qtbot.addWidget(itemEdit) - assert itemEdit.editName.text() == "Just a Page" - assert itemEdit.editStatus.currentData() == "Note" - assert itemEdit.editLayout.currentData() == nwItemLayout.DOCUMENT - itemEdit._doClose() - - # Check that the header is updated + # Check that the editor header is updated nwGUI.docEditor.updateDocInfo("0e17daca5f3e1") - assert nwGUI.docEditor.docHeader.theTitle.text() == "Novel › New Chapter › Just a Page" - assert not nwGUI.docEditor.setCursorLine("where?") - assert nwGUI.docEditor.setCursorLine(2) - qtbot.wait(stepDelay) - assert nwGUI.docEditor.getCursorPosition() == 15 - - qtbot.wait(stepDelay) - assert nwGUI.saveProject() - qtbot.wait(stepDelay) - - # Check the files - copyfile(projFile, testFile) - assert cmpFiles(testFile, compFile, [2, 6, 7, 8]) + assert nwGUI.docEditor.docHeader.theTitle.text() == "Novel › New Chapter › Great Scene" + itemEdit.close() + del itemEdit # qtbot.stopForInteraction() # END Test testDlgItemEditor_Dialog + + +@pytest.mark.gui +def testDlgItemEditor_Note(qtbot, monkeypatch, nwGUI, fncProj): + """Test the item editor dialog for a project note. + """ + # Block message box + monkeypatch.setattr(QMessageBox, "question", lambda *a: QMessageBox.Yes) + monkeypatch.setattr(GuiProjectTree, "hasFocus", lambda *a: True) + + # Create Project and Open Document + nwGUI.theProject.projTree.setSeed(42) + assert nwGUI.newProject({"projPath": fncProj}) + + # Create Note + nwGUI.treeView.clearSelection() + nwGUI.treeView._getTreeItem("71ee45a3c0db9").setSelected(True) + nwGUI.treeView.newTreeItem(nwItemType.FILE, None) + + # Open Note + assert nwGUI.openDocument("1a6562590ef19") + + # Edit a Document + itemEdit = GuiItemEditor(nwGUI, "1a6562590ef19") + itemEdit.show() + + # Check Existing Settings + assert itemEdit.editName.text() == "New File" + assert itemEdit.editStatus.currentData() == "New" + assert itemEdit.editLayout.currentData() == nwItemLayout.NOTE + assert itemEdit.editExport.isChecked() is True + + # Change Settings + itemEdit.editName.setText("New Character") + itemEdit.editStatus.setCurrentIndex(1) + itemEdit.editExport.setChecked(False) + + # Check New Settings + itemEdit._doSave() + assert itemEdit.theItem.itemName == "New Character" + assert itemEdit.theItem.itemStatus == "Minor" + assert itemEdit.theItem.itemLayout == nwItemLayout.NOTE + assert itemEdit.theItem.isExported is False + + itemEdit.close() + del itemEdit + # qtbot.stopForInteraction() + +# END Test testDlgItemEditor_Note + + +@pytest.mark.gui +def testDlgItemEditor_Folder(qtbot, monkeypatch, nwGUI, fncProj): + """Test the item editor dialog for a folder. + """ + # Block message box + monkeypatch.setattr(QMessageBox, "question", lambda *a: QMessageBox.Yes) + monkeypatch.setattr(GuiProjectTree, "hasFocus", lambda *a: True) + + # Create Project and Open Document + nwGUI.theProject.projTree.setSeed(42) + assert nwGUI.newProject({"projPath": fncProj}) + + # Edit a Folder + itemEdit = GuiItemEditor(nwGUI, "31489056e0916") + itemEdit.show() + + # Check Existing Settings + assert itemEdit.editName.text() == "New Chapter" + assert itemEdit.editStatus.currentData() == "New" + assert itemEdit.editLayout.currentData() == nwItemLayout.NO_LAYOUT + assert itemEdit.editExport.isChecked() is False + + assert itemEdit.editLayout.isEnabled() is False + assert itemEdit.editExport.isEnabled() is False + + # Change Settings + itemEdit.editName.setText("Chapter One") + itemEdit.editStatus.setCurrentIndex(1) + + # Check New Settings + itemEdit._doSave() + assert itemEdit.theItem.itemName == "Chapter One" + assert itemEdit.theItem.itemStatus == "Note" + assert itemEdit.theItem.itemLayout == nwItemLayout.NO_LAYOUT + assert itemEdit.theItem.isExported is False + + itemEdit.close() + del itemEdit + # qtbot.stopForInteraction() + +# END Test testDlgItemEditor_Folder