diff --git a/nw/__init__.py b/nw/__init__.py index d562d446..f3766e6c 100644 --- a/nw/__init__.py +++ b/nw/__init__.py @@ -87,6 +87,7 @@ def main(sysArgs=None): "logfile=", "version", "config=", + "data=", "testmode", "style=", ] @@ -105,6 +106,7 @@ def main(sysArgs=None): " -l, --logfile= Specify log file.\n" " --style= Set Qt5 style flag. Defaults to 'Fusion'.\n" " --config= Alternative config file.\n" + " --data= Alternative user data path.\n" " --headless Do not display GUI. Useful for testing scripts.\n" ).format( appname = __package__, @@ -120,6 +122,7 @@ def main(sysArgs=None): toFile = False toStd = True confPath = None + dataPath = None testMode = False qtStyle = "Fusion" cmdOpen = None @@ -157,6 +160,8 @@ def main(sysArgs=None): qtStyle = inArg elif inOpt in ("--config"): confPath = inArg + elif inOpt in ("--data"): + dataPath = inArg elif inOpt in ("--testmode"): testMode = True @@ -187,7 +192,7 @@ def main(sysArgs=None): logger.setLevel(debugLevel) - CONFIG.initConfig(confPath) + CONFIG.initConfig(confPath, dataPath) if testMode: nwGUI = GuiMain() diff --git a/nw/common.py b/nw/common.py index 8aa0a0f0..6764d244 100644 --- a/nw/common.py +++ b/nw/common.py @@ -88,6 +88,25 @@ def colRange(rgbStart, rgbEnd, nStep): return retCol +def formatInt(theInt): + """Formats an integer with k, M, G etc. + """ + postFix = ["k","M","G","T","P","E"] + theVal = float(theInt) + + if theVal > 1000.0: + for pF in postFix: + theVal /= 1000.0 + if theVal < 1000.0: + if theVal < 10.0: + return "%4.2f%s" % (theVal,pF) + elif theVal < 100.0: + return "%4.1f%s" % (theVal,pF) + else: + return "%3.0f%s" % (theVal,pF) + + return "%d" % theInt + def splitVersionNumber(vString): """ Splits a version string on the form aa.bb.cc into major, minor and patch, and computes an integer value aabbcc. diff --git a/nw/config.py b/nw/config.py index 5d8b3ec9..3149edef 100644 --- a/nw/config.py +++ b/nw/config.py @@ -12,10 +12,11 @@ import logging import configparser +import json import sys import nw -from os import path, mkdir +from os import path, mkdir, unlink, rename from datetime import datetime from PyQt5.Qt import PYQT_VERSION_STR @@ -116,9 +117,6 @@ class Config: self.showRefPanel = True self.viewComments = True - ## Path - self.recentList = [""]*10 - # Check Qt5 Versions verQt = splitVersionNumber(QT_VERSION_STR) self.verQtString = QT_VERSION_STR @@ -162,13 +160,19 @@ class Config: self.hasEnchant = False self.hasSymSpell = False + # Recent Cache + self.recentProj = {} + return ## # Actions ## - def initConfig(self, confPath=None): + def initConfig(self, confPath=None, dataPath=None): + """Initialise the config class. The manual setting of confPath + and dataPath is mainly intended for the test suite. + """ if confPath is None: confRoot = QStandardPaths.writableLocation(QStandardPaths.ConfigLocation) @@ -177,11 +181,15 @@ class Config: logger.info("Setting config from alternative path: %s" % confPath) self.confPath = confPath - if self.verQtValue >= 50400: - dataRoot = QStandardPaths.writableLocation(QStandardPaths.AppDataLocation) + if dataPath is None: + if self.verQtValue >= 50400: + dataRoot = QStandardPaths.writableLocation(QStandardPaths.AppDataLocation) + else: + dataRoot = QStandardPaths.writableLocation(QStandardPaths.DataLocation) + self.dataPath = path.join(path.abspath(dataRoot), self.appHandle) else: - dataRoot = QStandardPaths.writableLocation(QStandardPaths.DataLocation) - self.dataPath = path.join(path.abspath(dataRoot), self.appHandle) + logger.info("Setting data path from alternative path: %s" % dataPath) + self.dataPath = dataPath logger.verbose("Config path: %s" % self.confPath) logger.verbose("Data path: %s" % self.dataPath) @@ -212,25 +220,30 @@ class Config: self.confPath = None # Check if config file exists - if path.isfile(path.join(self.confPath,self.confFile)): - # If it exists, load it - self.loadConfig() - else: - # If it does not exist, save a copy of the default values - self.saveConfig() + if self.confPath is not None: + if path.isfile(path.join(self.confPath,self.confFile)): + # If it exists, load it + self.loadConfig() + else: + # If it does not exist, save a copy of the default values + self.saveConfig() # If data folder does not exist, make it. # This assumes that the os data folder itself exists. - if not path.isdir(self.dataPath): - try: - mkdir(self.dataPath) - except Exception as e: - logger.error("Could not create folder: %s" % self.dataPath) - logger.error(str(e)) - self.hasError = True - self.errData.append("Could not create folder: %s" % self.dataPath) - self.errData.append(str(e)) - self.dataPath = None + if self.dataPath is not None: + if not path.isdir(self.dataPath): + try: + mkdir(self.dataPath) + except Exception as e: + logger.error("Could not create folder: %s" % self.dataPath) + logger.error(str(e)) + self.hasError = True + self.errData.append("Could not create folder: %s" % self.dataPath) + self.errData.append(str(e)) + self.dataPath = None + + # Load recent projects cache + self.loadRecentCache() # Check the availability of optional packages self._checkOptionalPackages() @@ -389,10 +402,6 @@ class Config: self.lastPath = self._parseLine( cnfParse, cnfSec, "lastpath", self.CNF_STR, self.lastPath ) - for i in range(10): - self.recentList[i] = self._parseLine( - cnfParse, cnfSec, "recent%d" % i,self.CNF_STR, self.recentList[i] - ) # Check Certain Values for None self.spellLanguage = self._checkNone(self.spellLanguage) @@ -471,8 +480,6 @@ class Config: cnfSec = "Path" cnfParse.add_section(cnfSec) cnfParse.set(cnfSec,"lastpath", str(self.lastPath)) - for i in range(10): - cnfParse.set(cnfSec,"recent%d" % i, str(self.recentList[i])) # Write config file try: @@ -488,21 +495,86 @@ class Config: return True + def loadRecentCache(self): + """Load the cache file for recent projects. + """ + + if self.dataPath is None: + return False + + cacheFile = path.join(self.dataPath, nwFiles.RECENT_FILE) + self.recentProj = {} + + if path.isfile(cacheFile): + try: + with open(cacheFile, mode="r", encoding="utf8") as inFile: + theJson = inFile.read() + theData = json.loads(theJson) + + 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, + } + + 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 + + def saveRecentCache(self): + """Save the cache dictionary of recent projects. + """ + + if self.dataPath is None: + return False + + cacheFile = path.join(self.dataPath, nwFiles.RECENT_FILE) + cacheTemp = path.join(self.dataPath, nwFiles.RECENT_FILE+"~") + + try: + with open(cacheTemp, mode="w+", encoding="utf8") as outFile: + outFile.write(json.dumps(self.recentProj, indent=2)) + except Exception as e: + self.hasError = True + self.errData.append("Could not save recent project cache") + self.errData.append(str(e)) + return False + + if path.isfile(cacheFile): + unlink(cacheFile) + rename(cacheTemp, cacheFile) + + return True + + def updateRecentCache(self, projPath, projTitle, wordCount, saveTime): + """Add or update recent cache information o9n a given project. + """ + self.recentProj[path.abspath(projPath)] = { + "title" : projTitle, + "time" : int(saveTime), + "words" : int(wordCount), + } + return True + ## # Setters and Getters ## - def setRecent(self, recentPath): - if recentPath == "": return - if recentPath in self.recentList[0:10]: - self.recentList.remove(recentPath) - self.recentList.insert(0,recentPath) - return - - def clearRecent(self): - self.recentList = [""]*10 - return - def setConfPath(self, newPath): if newPath is None: return True @@ -513,6 +585,15 @@ class Config: self.confFile = path.basename(newPath) return True + def setDataPath(self, newPath): + if newPath is None: + return True + if not path.isdir(newPath): + logger.error("Config: Path not found. Using default data path instead.") + return False + self.dataPath = path.abspath(newPath) + return True + def setLastPath(self, lastPath): if lastPath is None or lastPath == "": self.lastPath = "" @@ -547,12 +628,12 @@ class Config: def setShowRefPanel(self, checkState): self.showRefPanel = checkState self.confChanged = True - return + return self.showRefPanel def setViewComments(self, checkState): self.viewComments = checkState self.confChanged = True - return + return self.viewComments def getErrData(self): errMessage = "
".join(self.errData) @@ -596,7 +677,7 @@ class Config: if checkVal is None: return None if isinstance(checkVal, str): - if checkVal.lower == "none": + if checkVal.lower() == "none": return None return checkVal diff --git a/nw/constants/constants.py b/nw/constants/constants.py index 3a24e4ff..4cc7f040 100644 --- a/nw/constants/constants.py +++ b/nw/constants/constants.py @@ -20,12 +20,13 @@ class nwConst(): class nwFiles(): - APP_ICON = "novelWriter.svg" - PROJ_FILE = "nwProject.nwx" - PROJ_DICT = "wordlist.txt" - SESS_INFO = "sessionInfo.log" - INDEX_FILE = "tagsIndex.json" - OPTS_FILE = "guiOptions.json" + APP_ICON = "novelWriter.svg" + PROJ_FILE = "nwProject.nwx" + PROJ_DICT = "wordlist.txt" + SESS_INFO = "sessionInfo.log" + INDEX_FILE = "tagsIndex.json" + OPTS_FILE = "guiOptions.json" + RECENT_FILE = "recentProjects.json" # END Class nwFiles diff --git a/nw/gui/__init__.py b/nw/gui/__init__.py index d8842bcc..48896ba1 100644 --- a/nw/gui/__init__.py +++ b/nw/gui/__init__.py @@ -13,6 +13,7 @@ from nw.gui.dialogs.docsplit import GuiDocSplit from nw.gui.dialogs.export import GuiExport from nw.gui.dialogs.itemeditor import GuiItemEditor from nw.gui.dialogs.projecteditor import GuiProjectEditor +from nw.gui.dialogs.projectload import GuiProjectLoad from nw.gui.dialogs.sessionlog import GuiSessionLogView from nw.gui.dialogs.timelineview import GuiTimeLineView @@ -40,6 +41,7 @@ __all__ = [ "GuiExport", "GuiItemEditor", "GuiProjectEditor", + "GuiProjectLoad", "GuiSessionLogView", "GuiTimeLineView", "GuiDocDetails", diff --git a/nw/gui/dialogs/__init__.py b/nw/gui/dialogs/__init__.py index a58b4982..49b10df3 100644 --- a/nw/gui/dialogs/__init__.py +++ b/nw/gui/dialogs/__init__.py @@ -6,6 +6,7 @@ from nw.gui.dialogs.docsplit import GuiDocSplit from nw.gui.dialogs.export import GuiExport from nw.gui.dialogs.itemeditor import GuiItemEditor from nw.gui.dialogs.projecteditor import GuiProjectEditor +from nw.gui.dialogs.projectload import GuiProjectLoad from nw.gui.dialogs.sessionlog import GuiSessionLogView from nw.gui.dialogs.timelineview import GuiTimeLineView @@ -16,6 +17,7 @@ __all__ = [ "GuiExport", "GuiItemEditor", "GuiProjectEditor", + "GuiProjectLoad", "GuiSessionLogView", "GuiTimeLineView", ] diff --git a/nw/gui/dialogs/projectload.py b/nw/gui/dialogs/projectload.py new file mode 100644 index 00000000..3e773a9d --- /dev/null +++ b/nw/gui/dialogs/projectload.py @@ -0,0 +1,169 @@ +# -*- coding: utf-8 -*- +"""novelWriter GUI Open Project + + novelWriter – GUI Open Project +================================ + New and open project dialog + + File History: + Created: 2020-02-26 [0.4.5] + +""" + +import logging +import nw + +from datetime import datetime + +from PyQt5.QtCore import Qt +from PyQt5.QtWidgets import ( + QDialog, QHBoxLayout, QVBoxLayout, QGridLayout, QPushButton, QTreeWidget, + QAbstractItemView, QTreeWidgetItem +) + +from nw.common import formatInt + +logger = logging.getLogger(__name__) + +class GuiProjectLoad(QDialog): + + def __init__(self, theParent): + QDialog.__init__(self, theParent) + + logger.debug("Initialising GuiProjectLoad ...") + + self.mainConf = nw.CONFIG + self.theParent = theParent + self.sourceItem = None + self.openPath = None + + self.outerBox = QHBoxLayout() + self.innerBox = QVBoxLayout() + self.setWindowTitle("Open Project") + self.setLayout(self.outerBox) + + self.guiDeco = self.theParent.theTheme.loadDecoration("nwicon", (128, 128)) + + self.outerBox.addWidget(self.guiDeco, 0, Qt.AlignTop) + self.outerBox.addLayout(self.innerBox) + + self.projectForm = QGridLayout() + self.projectForm.setContentsMargins(0, 0, 0, 0) + + self.listBox = QTreeWidget() + self.listBox.setSelectionMode(QAbstractItemView.SingleSelection) + self.listBox.setDragDropMode(QAbstractItemView.NoDragDrop) + self.listBox.setColumnCount(4) + self.listBox.setHeaderLabels(["Working Title","Words","Accessed","Path"]) + self.listBox.setRootIsDecorated(False) + + treeHead = self.listBox.headerItem() + treeHead.setTextAlignment(1, Qt.AlignRight) + + self.recentButton = QPushButton("Open") + self.recentButton.clicked.connect(self._doOpenRecent) + self.browseButton = QPushButton("Browse") + self.browseButton.clicked.connect(self._doBrowse) + self.closeButton = QPushButton("Close") + self.closeButton.clicked.connect(self._doClose) + + self.projectForm.addWidget(self.listBox, 0, 0, 1, 4) + self.projectForm.addWidget(self.recentButton, 1, 1) + self.projectForm.addWidget(self.browseButton, 1, 2) + self.projectForm.addWidget(self.closeButton, 1, 3) + self.projectForm.setColumnStretch(0, 1) + + self.innerBox.addLayout(self.projectForm) + + self.rejected.connect(self._doClose) + self.setModal(True) + self.setMinimumWidth(750) + self.setMinimumHeight(450) + self.show() + + self._populateList() + + logger.debug("GuiProjectLoad initialisation complete") + + return + + ## + # Buttons + ## + + def _doOpenRecent(self): + """Close the dialog window with a recent project selected. + """ + logger.verbose("GuiProjectLoad open button clicked") + + selItems = self.listBox.selectedItems() + if selItems: + self.openPath = selItems[0].text(3) + self.accept() + else: + self.openPath = None + + return + + def _doBrowse(self): + """Close the dialog window with no selected path, triggering the + project browser dialog. + """ + logger.verbose("GuiProjectLoad browse button clicked") + self.openPath = None + self.accept() + return + + def _doClose(self): + """Close the dialog window without doing anything. + """ + logger.verbose("GuiProjectLoad close button clicked") + self.close() + return + + ## + # Internal Functions + ## + + def _populateList(self): + """Populate the list box with recent project data. + """ + + listOrder = [] + listData = {} + for projPath in self.mainConf.recentProj.keys(): + theEntry = self.mainConf.recentProj[projPath] + theTitle = "" + theTime = 0 + theWords = 0 + if "title" in theEntry.keys(): + theTitle = theEntry["title"] + if "time" in theEntry.keys(): + theTime = theEntry["time"] + if "words" in theEntry.keys(): + theWords = theEntry["words"] + if theTime > 0: + listOrder.append(theTime) + listData[theTime] = [theTitle, theWords, projPath] + + self.listBox.clear() + hasSelection = False + for timeStamp in sorted(listOrder, reverse=True): + newItem = QTreeWidgetItem([""]*4) + newItem.setText(0, listData[timeStamp][0]) + newItem.setText(1, formatInt(listData[timeStamp][1])) + newItem.setText(2, datetime.fromtimestamp(timeStamp).strftime("%x %X")) + newItem.setText(3, listData[timeStamp][2]) + newItem.setTextAlignment(1, Qt.AlignRight) + self.listBox.addTopLevelItem(newItem) + if not hasSelection: + newItem.setSelected(True) + hasSelection = True + + self.listBox.resizeColumnToContents(0) + self.listBox.resizeColumnToContents(1) + self.listBox.resizeColumnToContents(2) + + return + +# END Class GuiProjectLoad diff --git a/nw/gui/icons.py b/nw/gui/icons.py index facb84d7..a6a68db0 100644 --- a/nw/gui/icons.py +++ b/nw/gui/icons.py @@ -45,10 +45,11 @@ class GuiIcons: } DECO_MAP = { - "export" : "export.svg", - "merge" : "merge.svg", - "settings" : "gear.svg", - "split" : "split.svg", + "nwicon" : ["icons", "novelWriter.svg"], + "export" : ["graphics", "export.svg"], + "merge" : ["graphics", "merge.svg"], + "settings" : ["graphics", "gear.svg"], + "split" : ["graphics", "split.svg"], } def __init__(self, theParent): @@ -67,6 +68,9 @@ class GuiIcons: return def initIcons(self, priPath): + """Load all icons listed in the icon map. Can be overridden by + the selected theme. + """ self.priPath = priPath self.secPath = self.mainConf.iconPath @@ -78,12 +82,19 @@ class GuiIcons: return def loadDecoration(self, decoKey, decoSize=None): + """Load graphical decoration element based on the decoration + map. This function always returns a QSwgWidget. + """ if decoKey not in self.DECO_MAP: logger.error("Decoration with name '%s' does not exist" % decoKey) return QSvgWidget() - svgPath = path.join(self.mainConf.graphPath, self.DECO_MAP[decoKey]) + svgPath = path.join( + self.mainConf.assetPath, + self.DECO_MAP[decoKey][0], + self.DECO_MAP[decoKey][1] + ) if not path.isfile(svgPath): logger.error("Decoration file '%s' not in assets folder" % self.DECO_MAP[decoKey]) return QSvgWidget() @@ -95,11 +106,17 @@ class GuiIcons: return svgDeco def getIcon(self, iconKey, iconSize=None): + """Return an icon from the icon buffer. If it doesn't exist, + return an empty icon. + """ if iconKey in self.qIcons: return self.qIcons[iconKey] return QIcon() def getPixmap(self, iconKey, iconSize): + """Return an icon from the icon buffer as a QPixmap. If it + doesn't exist, return an empty QPixmap. + """ if iconKey in self.qIcons: return self.qIcons[iconKey].pixmap(iconSize[0], iconSize[1], QIcon.Normal) return QPixmap() @@ -109,6 +126,11 @@ class GuiIcons: ## def _loadIcon(self, iconKey): + """Load an icon from the assets or theme folder, with a + preference for dark/light icons depending on theme type, if such + an icon exists. Prefer svg files over png files. Always returns + a QIcon. + """ if iconKey not in self.ICON_MAP: logger.error("Icon with name '%s' does not exist" % iconKey) diff --git a/nw/gui/mainmenu.py b/nw/gui/mainmenu.py index 14ff2279..1c13d81a 100644 --- a/nw/gui/mainmenu.py +++ b/nw/gui/mainmenu.py @@ -48,11 +48,6 @@ class GuiMainMenu(QMenuBar): return - def openRecentProject(self, menuItem, recentItem): - logger.verbose("User requested opening recent project #%d" % recentItem) - self.theParent.openProject(self.mainConf.recentList[recentItem]) - return True - def setAvailableRoot(self): for itemClass in nwItemClass: if itemClass == nwItemClass.NO_CLASS: continue @@ -66,30 +61,6 @@ class GuiMainMenu(QMenuBar): # Update Menu on Settings Changed ## - def updateMenu(self): - self.updateRecentProjects() - return - - def updateRecentProjects(self): - - self.recentMenu.clear() - for n in range(len(self.mainConf.recentList)): - recentProject = self.mainConf.recentList[n] - if recentProject == "": continue - menuItem = QAction("%s" % recentProject, self.projMenu) - menuItem.triggered.connect( - lambda a1=menuItem, a2=n : self.openRecentProject(a1, a2) - ) - self.recentMenu.addAction(menuItem) - - self.recentMenu.addSeparator() - menuItem = QAction("Clear Recent Projects", self) - menuItem.setStatusTip("Clear the list of recent projects") - menuItem.triggered.connect(self._clearRecentProjects) - self.recentMenu.addAction(menuItem) - - return - def setSpellCheck(self, theMode): """Set the spell check check box to theMode. This is controlled by the document editor class, which holds the master spell check @@ -166,11 +137,6 @@ class GuiMainMenu(QMenuBar): QDesktopServices.openUrl(QUrl(nw.__docurl__)) return True - def _clearRecentProjects(self): - self.mainConf.clearRecent() - self.updateRecentProjects() - return True - def _showDocumentLocation(self): self.theParent.docEditor.revealLocation() return True @@ -194,7 +160,7 @@ class GuiMainMenu(QMenuBar): self.aOpenProject = QAction("Open Project", self) self.aOpenProject.setStatusTip("Open project") self.aOpenProject.setShortcut("Ctrl+Shift+O") - self.aOpenProject.triggered.connect(lambda : self.theParent.openProject(None)) + self.aOpenProject.triggered.connect(self.theParent.manageProjects) self.projMenu.addAction(self.aOpenProject) # Project > Save Project @@ -211,10 +177,6 @@ class GuiMainMenu(QMenuBar): self.aCloseProject.triggered.connect(lambda : self.theParent.closeProject(False)) self.projMenu.addAction(self.aCloseProject) - # Project > Recent Projects - self.recentMenu = self.projMenu.addMenu("Recent Projects") - self.updateRecentProjects() - # Project > Project Settings self.aProjectSettings = QAction("Project Settings", self) self.aProjectSettings.setStatusTip("Project settings") diff --git a/nw/guimain.py b/nw/guimain.py index 2c0d15e9..a9cd7edb 100644 --- a/nw/guimain.py +++ b/nw/guimain.py @@ -27,7 +27,7 @@ from nw.gui import ( GuiMainMenu, GuiMainStatus, GuiTheme, GuiDocTree, GuiDocEditor, GuiExport, GuiDocViewer, GuiDocDetails, GuiSearchBar, GuiNoticeBar, GuiDocViewDetails, GuiConfigEditor, GuiProjectEditor, GuiItemEditor, GuiTimeLineView, - GuiSessionLogView, GuiDocMerge, GuiDocSplit + GuiSessionLogView, GuiDocMerge, GuiDocSplit, GuiProjectLoad ) from nw.project import NWProject, NWDoc, NWItem, NWIndex, NWBackup from nw.tools import countWords @@ -64,7 +64,7 @@ class GuiMain(QMainWindow): self.resize(*self.mainConf.winGeometry) self._setWindowTitle() - self.setWindowIcon(QIcon(path.join(self.mainConf.appIcon))) + self.setWindowIcon(QIcon(self.mainConf.appIcon)) # Main GUI Elements self.statusBar = GuiMainStatus(self) @@ -183,6 +183,8 @@ class GuiMain(QMainWindow): if self.mainConf.cmdOpen is not None: logger.debug("Opening project from additional command line option") self.openProject(self.mainConf.cmdOpen) + else: + self.manageProjects() return @@ -204,13 +206,30 @@ class GuiMain(QMainWindow): # Project Actions ## + def manageProjects(self): + """Opens 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. + """ + if not self.mainConf.showGUI: + return False + + dlgProj = GuiProjectLoad(self) + dlgProj.exec_() + if dlgProj.result() == QDialog.Accepted: + self.openProject(dlgProj.openPath) + + return True + def newProject(self, projPath=None, forceNew=False): + """Create new project with a few default files and folders. + """ if self.hasProject: msgBox = QMessageBox() msgRes = msgBox.warning( self, "New Project", - "Please close the current project
before making a new one." + "Please close the current project before making a new one." ) return False @@ -223,7 +242,7 @@ class GuiMain(QMainWindow): msgBox = QMessageBox() msgRes = msgBox.critical( self, "New Project", - "A project already exists in that location.
Please choose another folder." + "A project already exists in that location. Please choose another folder." ) return False @@ -238,9 +257,9 @@ class GuiMain(QMainWindow): return True def closeProject(self, isYes=False): - """Closes the project if one is open. - isYes is passed on from the close application event so the user - doesn't get prompted twice. + """Closes the project if one is open. isYes is passed on from + the close application event so the user doesn't get prompted + twice. """ if not self.hasProject: # There is no project loaded, everything OK @@ -314,7 +333,6 @@ class GuiMain(QMainWindow): self.docEditor.setDictionaries() self.docEditor.setSpellCheck(self.theProject.spellCheck) self.statusBar.setRefTime(self.theProject.projOpened) - self.mainMenu.updateMenu() # Restore previously open documents, if any if self.theProject.lastEdited is not None: @@ -344,7 +362,6 @@ class GuiMain(QMainWindow): self.treeView.saveTreeOrder() self.theProject.saveProject() self.theIndex.saveIndex() - self.mainMenu.updateRecentProjects() return True diff --git a/nw/project/project.py b/nw/project/project.py index aac52f73..887fe38c 100644 --- a/nw/project/project.py +++ b/nw/project/project.py @@ -297,7 +297,11 @@ class NWProject(): self._appendItem(tHandle,pHandle,nwItem) self.optState.loadSettings() - self.mainConf.setRecent(self.projPath) + + # 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() @@ -319,6 +323,7 @@ class NWProject(): 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 @@ -330,7 +335,7 @@ class NWProject(): nwXML = etree.Element("novelWriterXML",attrib={ "appVersion" : str(nw.__version__), "fileVersion" : "1.0", - "timeStamp" : datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + "timeStamp" : datetime.fromtimestamp(saveTime).strftime("%Y-%m-%d %H:%M:%S"), }) # Save Project Meta @@ -386,8 +391,13 @@ class NWProject(): rename(saveFile, backFile) rename(tempFile, saveFile) + # Save project GUI options self.optState.saveSettings() - self.mainConf.setRecent(self.projPath) + + # Update recent projects + self.mainConf.updateRecentCache(self.projPath, self.projName, self.currWCount, saveTime) + self.mainConf.saveRecentCache() + self.theParent.setStatus("Saved Project: %s" % self.projName) self.setProjectChanged(False) diff --git a/tests/reference/novelwriter.conf b/tests/reference/novelwriter.conf index 79fb70a7..bac82e44 100644 --- a/tests/reference/novelwriter.conf +++ b/tests/reference/novelwriter.conf @@ -1,5 +1,5 @@ [Main] -timestamp = 2019-11-19 21:49:29 +timestamp = 2020-02-26 18:10:35 theme = default syntax = default_light guidark = False @@ -49,14 +49,4 @@ viewcomments = True [Path] lastpath = -recent0 = -recent1 = -recent2 = -recent3 = -recent4 = -recent5 = -recent6 = -recent7 = -recent8 = -recent9 = diff --git a/tests/test_config.py b/tests/test_config.py index 4c4c2523..a8562976 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -13,7 +13,7 @@ theConf = Config() def testConfigInit(nwTemp,nwRef): tmpConf = path.join(nwTemp,"novelwriter.conf") refConf = path.join(nwRef, "novelwriter.conf") - assert theConf.initConfig(nwTemp) + assert theConf.initConfig(nwTemp, nwTemp) assert theConf.setLastPath("") assert theConf.saveConfig() assert cmpFiles(tmpConf, refConf, [2]) @@ -37,6 +37,14 @@ def testConfigSetConfPath(nwTemp): assert theConf.confFile == "novelwriter.conf" assert not theConf.confChanged +@pytest.mark.core +def testConfigSetDataPath(nwTemp): + assert theConf.setDataPath(None) + assert not theConf.setDataPath(path.join("somewhere","over","the","rainbow")) + assert theConf.setDataPath(nwTemp) + assert theConf.dataPath == nwTemp + assert not theConf.confChanged + @pytest.mark.core def testConfigLoad(): assert theConf.loadConfig() @@ -67,7 +75,7 @@ def testConfigSetTreeColWidths(nwTemp,nwRef): assert not theConf.confChanged @pytest.mark.core -def testConfigSetMainPanePos(nwTemp,nwRef): +def testConfigSetPanePos(nwTemp,nwRef): tmpConf = path.join(nwTemp,"novelwriter.conf") refConf = path.join(nwRef, "novelwriter.conf") assert theConf.setMainPanePos([0, 0]) @@ -77,3 +85,31 @@ def testConfigSetMainPanePos(nwTemp,nwRef): assert theConf.saveConfig() assert cmpFiles(tmpConf, refConf, [2]) assert not theConf.confChanged + +@pytest.mark.core +def testConfigFlags(nwTemp,nwRef): + tmpConf = path.join(nwTemp,"novelwriter.conf") + refConf = path.join(nwRef, "novelwriter.conf") + assert not theConf.setShowRefPanel(False) + assert theConf.setShowRefPanel(True) + assert not theConf.setViewComments(False) + assert theConf.setViewComments(True) + assert theConf.confChanged + assert theConf.saveConfig() + assert cmpFiles(tmpConf, refConf, [2]) + assert not theConf.confChanged + +@pytest.mark.core +def testConfigErrors(nwTemp): + nonPath = path.join("somewhere","over","the","rainbow") + assert theConf.initConfig(nonPath, nonPath) + assert theConf.hasError + assert not theConf.loadConfig() + assert not theConf.saveConfig() + assert not theConf.loadRecentCache() + assert len(theConf.getErrData()) > 0 + +@pytest.mark.core +def testConfigInternals(): + assert theConf._checkNone(None) is None + assert theConf._checkNone("None") is None diff --git a/tests/test_gui.py b/tests/test_gui.py index dc279222..b039a9d8 100644 --- a/tests/test_gui.py +++ b/tests/test_gui.py @@ -18,8 +18,8 @@ keyDelay = 10 stepDelay = 50 @pytest.mark.gui -def testMainWindows(qtbot, nwTempGUI, nwRef): - nwGUI = nw.main(["--testmode","--config=%s" % nwTempGUI]) +def testMainWindows(qtbot, nwTempGUI, nwRef, nwTemp): + nwGUI = nw.main(["--testmode","--config=%s" % nwTempGUI, "--data=%s" % nwTemp]) qtbot.addWidget(nwGUI) nwGUI.show() qtbot.waitForWindowShown(nwGUI) @@ -254,8 +254,8 @@ def testMainWindows(qtbot, nwTempGUI, nwRef): # qtbot.stopForInteraction() @pytest.mark.gui -def testTimeLineView(qtbot, nwTempGUI, nwRef): - nwGUI = nw.main(["--testmode","--config=%s" % nwTempGUI]) +def testTimeLineView(qtbot, nwTempGUI, nwRef, nwTemp): + nwGUI = nw.main(["--testmode","--config=%s" % nwTempGUI, "--data=%s" % nwTemp]) qtbot.addWidget(nwGUI) nwGUI.show() qtbot.waitForWindowShown(nwGUI) @@ -276,8 +276,8 @@ def testTimeLineView(qtbot, nwTempGUI, nwRef): nwGUI.closeMain() @pytest.mark.gui -def testProjectEditor(qtbot, nwTempGUI, nwRef): - nwGUI = nw.main(["--testmode","--config=%s" % nwTempGUI]) +def testProjectEditor(qtbot, nwTempGUI, nwRef, nwTemp): + nwGUI = nw.main(["--testmode","--config=%s" % nwTempGUI, "--data=%s" % nwTemp]) qtbot.addWidget(nwGUI) nwGUI.show() qtbot.waitForWindowShown(nwGUI) @@ -363,8 +363,8 @@ def testProjectEditor(qtbot, nwTempGUI, nwRef): # qtbot.stopForInteraction() @pytest.mark.gui -def testItemEditor(qtbot, nwTempGUI, nwRef): - nwGUI = nw.main(["--testmode","--config=%s" % nwTempGUI]) +def testItemEditor(qtbot, nwTempGUI, nwRef, nwTemp): + nwGUI = nw.main(["--testmode","--config=%s" % nwTempGUI, "--data=%s" % nwTemp]) qtbot.addWidget(nwGUI) nwGUI.show() qtbot.waitForWindowShown(nwGUI) diff --git a/tests/test_project.py b/tests/test_project.py index 907d0e9f..c2f4078d 100644 --- a/tests/test_project.py +++ b/tests/test_project.py @@ -22,10 +22,10 @@ theProject = NWProject(theMain) theProject.handleSeed = 42 @pytest.mark.project -def testProjectNew(nwTempProj,nwRef): +def testProjectNew(nwTempProj,nwRef,nwTemp): projFile = path.join(nwTempProj,"nwProject.nwx") refFile = path.join(nwRef,"proj","1_nwProject.nwx") - assert theConf.initConfig(nwRef) + assert theConf.initConfig(nwRef, nwTemp) assert theProject.newProject() assert theProject.setProjectPath(nwTempProj) assert theProject.saveProject()