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()