From c688c66d5b2d585cb13bbe350c4908e76d268960 Mon Sep 17 00:00:00 2001
From: "Veronica K. B. Olsen" <1619840+vkbo@users.noreply.github.com>
Date: Wed, 26 Feb 2020 17:44:11 +0100
Subject: [PATCH] Project manager dialog now lists recent projects and lets the
user open one of these, or browse for a folder
---
nw/common.py | 19 ++++++++
nw/config.py | 77 +++++++++++++++++++++++++++++-
nw/constants/constants.py | 13 +++---
nw/gui/dialogs/projectload.py | 88 +++++++++++++++++++++++++++++++++--
nw/gui/mainmenu.py | 2 +-
nw/guimain.py | 18 ++++---
nw/project/project.py | 9 +++-
7 files changed, 205 insertions(+), 21 deletions(-)
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)