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..4cc9f500 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 @@ -162,6 +163,9 @@ class Config: self.hasEnchant = False self.hasSymSpell = False + # Recent Cache + self.recentProj = {} + return ## @@ -219,6 +223,9 @@ class Config: # If it does not exist, save a copy of the default values self.saveConfig() + # Load re3cent projects cache + self.loadRecentCache() + # If data folder does not exist, make it. # This assumes that the os data folder itself exists. if not path.isdir(self.dataPath): @@ -488,6 +495,74 @@ class Config: return True + def loadRecentCache(self): + """Load the cache file for recent projects. + """ + 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. + """ + 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 ## 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/dialogs/projectload.py b/nw/gui/dialogs/projectload.py index 9b075fbc..6fe7f92c 100644 --- a/nw/gui/dialogs/projectload.py +++ b/nw/gui/dialogs/projectload.py @@ -13,12 +13,16 @@ import logging import nw +from datetime import datetime + from PyQt5.QtCore import Qt from PyQt5.QtWidgets import ( - QDialog, QHBoxLayout, QVBoxLayout, QGridLayout, QPushButton, QListWidget, - QAbstractItemView + QDialog, QHBoxLayout, QVBoxLayout, QGridLayout, QPushButton, QTreeWidget, + QAbstractItemView, QTreeWidgetItem ) +from nw.common import formatInt + logger = logging.getLogger(__name__) class GuiProjectLoad(QDialog): @@ -31,6 +35,7 @@ class GuiProjectLoad(QDialog): self.mainConf = nw.CONFIG self.theParent = theParent self.sourceItem = None + self.openPath = None self.outerBox = QHBoxLayout() self.innerBox = QVBoxLayout() @@ -45,19 +50,35 @@ class GuiProjectLoad(QDialog): self.projectForm = QGridLayout() self.projectForm.setContentsMargins(0, 0, 0, 0) - self.listBox = QListWidget() + 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, 3) - self.projectForm.addWidget(self.closeButton, 1, 2) + 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() @@ -70,6 +91,29 @@ class GuiProjectLoad(QDialog): # 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. """ @@ -82,6 +126,40 @@ class GuiProjectLoad(QDialog): ## 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() + 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) + + self.listBox.resizeColumnToContents(0) + self.listBox.resizeColumnToContents(1) + self.listBox.resizeColumnToContents(2) + return # END Class GuiProjectLoad diff --git a/nw/gui/mainmenu.py b/nw/gui/mainmenu.py index 14ff2279..1548eaaf 100644 --- a/nw/gui/mainmenu.py +++ b/nw/gui/mainmenu.py @@ -194,7 +194,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 diff --git a/nw/guimain.py b/nw/guimain.py index 96fc99b0..19042480 100644 --- a/nw/guimain.py +++ b/nw/guimain.py @@ -207,13 +207,17 @@ class GuiMain(QMainWindow): ## 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 self.mainConf.showGUI: - dlgProj = GuiProjectLoad(self) - dlgProj.exec_() + if not self.mainConf.showGUI: + return False + dlgProj = GuiProjectLoad(self) + dlgProj.exec_() + if dlgProj.result() == QDialog.Accepted: + self.openProject(dlgProj.openPath) return True @@ -223,7 +227,7 @@ class GuiMain(QMainWindow): 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 @@ -236,7 +240,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 diff --git a/nw/project/project.py b/nw/project/project.py index aac52f73..fa5ee920 100644 --- a/nw/project/project.py +++ b/nw/project/project.py @@ -319,6 +319,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 +331,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 +387,14 @@ class NWProject(): rename(saveFile, backFile) rename(tempFile, saveFile) + # Save project GUI options self.optState.saveSettings() + + # Update recent projects self.mainConf.setRecent(self.projPath) + self.mainConf.updateRecentCache(self.projPath, self.projName, self.currWCount, saveTime) + self.mainConf.saveRecentCache() + self.theParent.setStatus("Saved Project: %s" % self.projName) self.setProjectChanged(False)