Remember last used path for each tool individually (#1934)

This commit is contained in:
Veronica Berglyd Olsen
2024-06-16 12:05:32 +02:00
committed by GitHub
13 changed files with 165 additions and 58 deletions
+67 -15
View File
@@ -5,6 +5,7 @@ novelWriter Config Class
File History: File History:
Created: 2018-09-22 [0.0.1] Config Created: 2018-09-22 [0.0.1] Config
Created: 2022-11-09 [2.0rc2] RecentProjects Created: 2022-11-09 [2.0rc2] RecentProjects
Created: 2024-06-16 [2.5rc1] RecentPaths
This file is a part of novelWriter This file is a part of novelWriter
Copyright 20182024, Veronica Berglyd Olsen Copyright 20182024, Veronica Berglyd Olsen
@@ -103,7 +104,8 @@ class Config:
# User Settings # User Settings
# ============= # =============
self._recentObj = RecentProjects(self) self._recentProjects = RecentProjects(self)
self._recentPaths = RecentPaths(self)
# General GUI Settings # General GUI Settings
self.guiLocale = self._qLocale.name() self.guiLocale = self._qLocale.name()
@@ -180,7 +182,6 @@ class Config:
self.fmtPadThin = False self.fmtPadThin = False
# User Paths # User Paths
self._lastPath = self._homePath # The user's last used path
self._backupPath = self._backPath # Backup path to use, can be none self._backupPath = self._backPath # Backup path to use, can be none
# Spell Checking Settings # Spell Checking Settings
@@ -253,7 +254,7 @@ class Config:
@property @property
def recentProjects(self) -> RecentProjects: def recentProjects(self) -> RecentProjects:
return self._recentObj return self._recentProjects
@property @property
def mainWinSize(self) -> list[int]: def mainWinSize(self) -> list[int]:
@@ -343,7 +344,7 @@ class Config:
self._outlnPanePos = [int(x/self.guiScale) for x in pos] self._outlnPanePos = [int(x/self.guiScale) for x in pos]
return return
def setLastPath(self, path: str | Path) -> None: def setLastPath(self, key: str, path: str | Path) -> None:
"""Set the last used path. Only the folder is saved, so if the """Set the last used path. Only the folder is saved, so if the
path is not a folder, the parent of the path is used instead. path is not a folder, the parent of the path is used instead.
""" """
@@ -352,8 +353,7 @@ class Config:
if not path.is_dir(): if not path.is_dir():
path = path.parent path = path.parent
if path.is_dir(): if path.is_dir():
self._lastPath = path self._recentPaths.setPath(key, path)
logger.debug("Last path updated: %s" % self._lastPath)
return return
def setBackupPath(self, path: Path | str) -> None: def setBackupPath(self, path: Path | str) -> None:
@@ -438,11 +438,12 @@ class Config:
return self._appPath / "assets" / target return self._appPath / "assets" / target
return self._appPath / "assets" return self._appPath / "assets"
def lastPath(self) -> Path: def lastPath(self, key: str) -> Path:
"""Return the last path used by the user, if it exists.""" """Return the last path used by the user, if it exists."""
if isinstance(self._lastPath, Path): if path := self._recentPaths.getPath(key):
if self._lastPath.is_dir(): asPath = Path(path)
return self._lastPath if asPath.is_dir():
return asPath
return self._homePath return self._homePath
def backupPath(self) -> Path: def backupPath(self) -> Path:
@@ -516,7 +517,6 @@ class Config:
logger.debug("Data Path: %s", self._dataPath) logger.debug("Data Path: %s", self._dataPath)
logger.debug("App Root: %s", self._appRoot) logger.debug("App Root: %s", self._appRoot)
logger.debug("App Path: %s", self._appPath) logger.debug("App Path: %s", self._appPath)
logger.debug("Last Path: %s", self._lastPath)
logger.debug("PDF Manual: %s", self.pdfDocs) logger.debug("PDF Manual: %s", self.pdfDocs)
# If the config and data folders don't exist, create them # If the config and data folders don't exist, create them
@@ -531,7 +531,8 @@ class Config:
(self._dataPath / "syntax").mkdir(exist_ok=True) (self._dataPath / "syntax").mkdir(exist_ok=True)
(self._dataPath / "themes").mkdir(exist_ok=True) (self._dataPath / "themes").mkdir(exist_ok=True)
self._recentObj.loadCache() self._recentPaths.loadCache()
self._recentProjects.loadCache()
self._checkOptionalPackages() self._checkOptionalPackages()
logger.debug("Config instance initialised") logger.debug("Config instance initialised")
@@ -600,7 +601,6 @@ class Config:
self.hideHScroll = conf.rdBool(sec, "hidehscroll", self.hideHScroll) self.hideHScroll = conf.rdBool(sec, "hidehscroll", self.hideHScroll)
self.lastNotes = conf.rdStr(sec, "lastnotes", self.lastNotes) self.lastNotes = conf.rdStr(sec, "lastnotes", self.lastNotes)
self.nativeFont = conf.rdBool(sec, "nativefont", self.nativeFont) self.nativeFont = conf.rdBool(sec, "nativefont", self.nativeFont)
self._lastPath = conf.rdPath(sec, "lastpath", self._lastPath)
# Sizes # Sizes
sec = "Sizes" sec = "Sizes"
@@ -710,7 +710,6 @@ class Config:
"hidehscroll": str(self.hideHScroll), "hidehscroll": str(self.hideHScroll),
"lastnotes": str(self.lastNotes), "lastnotes": str(self.lastNotes),
"nativefont": str(self.nativeFont), "nativefont": str(self.nativeFont),
"lastpath": str(self._lastPath),
} }
conf["Sizes"] = { conf["Sizes"] = {
@@ -811,7 +810,7 @@ class Config:
"""Pack a list of items into a comma-separated string for saving """Pack a list of items into a comma-separated string for saving
to the config file. to the config file.
""" """
return ", ".join([str(inVal) for inVal in data]) return ", ".join(str(inVal) for inVal in data)
def _checkOptionalPackages(self) -> None: def _checkOptionalPackages(self) -> None:
"""Check optional packages used by some features.""" """Check optional packages used by some features."""
@@ -893,3 +892,56 @@ class RecentProjects:
logger.debug("Removed recent: %s", path) logger.debug("Removed recent: %s", path)
self.saveCache() self.saveCache()
return return
class RecentPaths:
KEYS = ["default", "project", "import", "outline", "stats"]
def __init__(self, config: Config) -> None:
self._conf = config
self._data = {}
return
def setPath(self, key: str, path: Path | str) -> None:
"""Set a path for a given key, and save the cache."""
if key in self.KEYS:
self._data[key] = str(path)
self.saveCache()
return
def getPath(self, key: str) -> str | None:
"""Get a path for a given key, or return None."""
return self._data.get(key)
def loadCache(self) -> bool:
"""Load the cache file for recent paths."""
self._data = {}
cacheFile = self._conf.dataPath(nwFiles.RECENT_PATH)
if cacheFile.is_file():
try:
with open(cacheFile, mode="r", encoding="utf-8") as inFile:
data = json.load(inFile)
if isinstance(data, dict):
for key, path in data.items():
if key in self.KEYS and isinstance(path, str):
self._data[key] = path
except Exception:
logger.error("Could not load recent paths cache")
logException()
return False
return True
def saveCache(self) -> bool:
"""Save the cache dictionary of recent paths."""
cacheFile = self._conf.dataPath(nwFiles.RECENT_PATH)
cacheTemp = cacheFile.with_suffix(".tmp")
try:
with open(cacheTemp, mode="w+", encoding="utf-8") as outFile:
json.dump(self._data, outFile, indent=2)
cacheTemp.replace(cacheFile)
except Exception:
logger.error("Could not save recent paths cache")
logException()
return False
return True
+1
View File
@@ -104,6 +104,7 @@ class nwFiles:
# Config Files # Config Files
CONF_FILE = "novelwriter.conf" CONF_FILE = "novelwriter.conf"
RECENT_FILE = "recentProjects.json" RECENT_FILE = "recentProjects.json"
RECENT_PATH = "recentPaths.json"
# Project Root Files # Project Root Files
PROJ_FILE = "nwProject.nwx" PROJ_FILE = "nwProject.nwx"
+3 -3
View File
@@ -219,7 +219,7 @@ class BuildSettings:
return self._order return self._order
@property @property
def lastPath(self) -> Path: def lastBuildPath(self) -> Path:
"""The last used build path.""" """The last used build path."""
if self._path.is_dir(): if self._path.is_dir():
return self._path return self._path
@@ -293,7 +293,7 @@ class BuildSettings:
self._order = value self._order = value
return return
def setLastPath(self, path: Path | str | None) -> None: def setLastBuildPath(self, path: Path | str | None) -> None:
"""Set the last used build path.""" """Set the last used build path."""
if isinstance(path, str): if isinstance(path, str):
path = Path(path) path = Path(path)
@@ -461,7 +461,7 @@ class BuildSettings:
self.setName(data.get("name", "")) self.setName(data.get("name", ""))
self.setBuildID(data.get("uuid", "")) self.setBuildID(data.get("uuid", ""))
self.setOrder(data.get("order", 0)) self.setOrder(data.get("order", 0))
self.setLastPath(data.get("path", None)) self.setLastBuildPath(data.get("path", None))
self.setLastBuildName(data.get("build", "")) self.setLastBuildName(data.get("build", ""))
buildFmt = str(data.get("format", "")) buildFmt = str(data.get("format", ""))
+2 -2
View File
@@ -523,12 +523,12 @@ class GuiOutlineTree(QTreeWidget):
@pyqtSlot() @pyqtSlot()
def exportOutline(self) -> None: def exportOutline(self) -> None:
"""Export the outline as a CSV file.""" """Export the outline as a CSV file."""
path = CONFIG.lastPath() / f"{makeFileNameSafe(SHARED.project.data.name)}.csv" path = CONFIG.lastPath("outline") / f"{makeFileNameSafe(SHARED.project.data.name)}.csv"
path, _ = QFileDialog.getSaveFileName( path, _ = QFileDialog.getSaveFileName(
self, self.tr("Save Outline As"), str(path), formatFileFilter(["*.csv", "*"]) self, self.tr("Save Outline As"), str(path), formatFileFilter(["*.csv", "*"])
) )
if path: if path:
CONFIG.setLastPath(path) CONFIG.setLastPath("outline", path)
logger.info("Writing CSV file: %s", path) logger.info("Writing CSV file: %s", path)
cols = [col for col in self._treeOrder if not self._colHidden[col]] cols = [col for col in self._treeOrder if not self._colHidden[col]]
order = [self._colIdx[col] for col in cols] order = [self._colIdx[col] for col in cols]
+2 -2
View File
@@ -652,7 +652,7 @@ class GuiMain(QMainWindow):
logger.error("No project open") logger.error("No project open")
return False return False
lastPath = CONFIG.lastPath() lastPath = CONFIG.lastPath("import")
ffilter = formatFileFilter(["*.txt", "*.md", "*.nwd", "*"]) ffilter = formatFileFilter(["*.txt", "*.md", "*.nwd", "*"])
loadFile, _ = QFileDialog.getOpenFileName( loadFile, _ = QFileDialog.getOpenFileName(
self, self.tr("Import File"), str(lastPath), filter=ffilter self, self.tr("Import File"), str(lastPath), filter=ffilter
@@ -667,7 +667,7 @@ class GuiMain(QMainWindow):
try: try:
with open(loadFile, mode="rt", encoding="utf-8") as inFile: with open(loadFile, mode="rt", encoding="utf-8") as inFile:
text = inFile.read() text = inFile.read()
CONFIG.setLastPath(loadFile) CONFIG.setLastPath("import", loadFile)
except Exception as exc: except Exception as exc:
SHARED.error(self.tr( SHARED.error(self.tr(
"Could not read file. The file must be an existing text file." "Could not read file. The file must be an existing text file."
+3 -3
View File
@@ -220,7 +220,7 @@ class GuiManuscriptBuild(NDialog):
self.btnBuild.setFocus() self.btnBuild.setFocus()
self._populateContentList() self._populateContentList()
self.buildPath.setText(str(self._build.lastPath)) self.buildPath.setText(str(self._build.lastBuildPath))
if self._build.lastBuildName: if self._build.lastBuildName:
self.buildName.setText(self._build.lastBuildName) self.buildName.setText(self._build.lastBuildName)
else: else:
@@ -274,7 +274,7 @@ class GuiManuscriptBuild(NDialog):
def _doSelectPath(self) -> None: def _doSelectPath(self) -> None:
"""Select a folder for output.""" """Select a folder for output."""
bPath = Path(self.buildPath.text()) bPath = Path(self.buildPath.text())
bPath = bPath if bPath.is_dir() else self._build.lastPath bPath = bPath if bPath.is_dir() else self._build.lastBuildPath
savePath = QFileDialog.getExistingDirectory( savePath = QFileDialog.getExistingDirectory(
self, self.tr("Select Folder"), str(bPath) self, self.tr("Select Folder"), str(bPath)
) )
@@ -336,7 +336,7 @@ class GuiManuscriptBuild(NDialog):
for i, _ in docBuild.iterBuild(buildPath, bFormat): for i, _ in docBuild.iterBuild(buildPath, bFormat):
self.buildProgress.setValue(i+1) self.buildProgress.setValue(i+1)
self._build.setLastPath(bPath) self._build.setLastBuildPath(bPath)
self._build.setLastBuildName(bName) self._build.setLastBuildName(bName)
self._build.setLastFormat(bFormat) self._build.setLastFormat(bFormat)
+5 -5
View File
@@ -220,8 +220,7 @@ class GuiWelcome(NDialog):
@pyqtSlot() @pyqtSlot()
def _browseForProject(self) -> None: def _browseForProject(self) -> None:
"""Browse for a project to open.""" """Browse for a project to open."""
if path := SHARED.getProjectPath(self, path=CONFIG.lastPath(), allowZip=False): if path := SHARED.getProjectPath(self, path=CONFIG.homePath(), allowZip=False):
CONFIG.setLastPath(path)
self._openProjectPath(path) self._openProjectPath(path)
return return
@@ -550,7 +549,7 @@ class _NewProjectForm(QWidget):
def __init__(self, parent: QWidget) -> None: def __init__(self, parent: QWidget) -> None:
super().__init__(parent=parent) super().__init__(parent=parent)
self._basePath = CONFIG.homePath() self._basePath = CONFIG.lastPath("project")
self._fillMode = self.FILL_BLANK self._fillMode = self.FILL_BLANK
self._copyPath = None self._copyPath = None
@@ -726,12 +725,13 @@ class _NewProjectForm(QWidget):
@pyqtSlot() @pyqtSlot()
def _doBrowse(self) -> None: def _doBrowse(self) -> None:
"""Select a project folder.""" """Select a project folder."""
if projDir := QFileDialog.getExistingDirectory( if path := QFileDialog.getExistingDirectory(
self, self.tr("Select Project Folder"), self, self.tr("Select Project Folder"),
str(self._basePath), options=QFileDialog.Option.ShowDirsOnly str(self._basePath), options=QFileDialog.Option.ShowDirsOnly
): ):
self._basePath = Path(projDir) self._basePath = Path(path)
self._updateProjPath() self._updateProjPath()
CONFIG.setLastPath("project", path)
return return
@pyqtSlot() @pyqtSlot()
+2 -2
View File
@@ -384,14 +384,14 @@ class GuiWritingStats(NToolDialog):
return False return False
# Generate the file name # Generate the file name
savePath = CONFIG.lastPath() / f"sessionStats.{fileExt}" savePath = CONFIG.lastPath("stats") / f"sessionStats.{fileExt}"
savePath, _ = QFileDialog.getSaveFileName( savePath, _ = QFileDialog.getSaveFileName(
self, self.tr("Save Data As"), str(savePath), f"{textFmt} (*.{fileExt})" self, self.tr("Save Data As"), str(savePath), f"{textFmt} (*.{fileExt})"
) )
if not savePath: if not savePath:
return False return False
CONFIG.setLastPath(savePath) CONFIG.setLastPath("stats", savePath)
# Do the actual writing # Do the actual writing
wSuccess = False wSuccess = False
-1
View File
@@ -50,7 +50,6 @@ def resetConfigVars():
"""Reset the CONFIG object and set various values for testing to """Reset the CONFIG object and set various values for testing to
prevent interfering with local OS. prevent interfering with local OS.
""" """
CONFIG.setLastPath(_TMP_ROOT)
CONFIG.setBackupPath(_TMP_ROOT) CONFIG.setBackupPath(_TMP_ROOT)
CONFIG.setGuiFont(None) CONFIG.setGuiFont(None)
CONFIG.setTextFont(None) CONFIG.setTextFont(None)
+1 -2
View File
@@ -1,5 +1,5 @@
[Meta] [Meta]
timestamp = 2024-05-20 16:48:20 timestamp = 2024-06-16 00:36:27
[Main] [Main]
font = font =
@@ -10,7 +10,6 @@ hidevscroll = False
hidehscroll = False hidehscroll = False
lastnotes = 0x0 lastnotes = 0x0
nativefont = True nativefont = True
lastpath =
[Sizes] [Sizes]
mainwindow = 1200, 650 mainwindow = 1200, 650
+63 -7
View File
@@ -20,6 +20,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
""" """
from __future__ import annotations from __future__ import annotations
import json
import sys import sys
from pathlib import Path from pathlib import Path
@@ -28,7 +29,7 @@ from shutil import copyfile
import pytest import pytest
from novelwriter import CONFIG from novelwriter import CONFIG
from novelwriter.config import Config, RecentProjects from novelwriter.config import Config, RecentPaths, RecentProjects
from novelwriter.constants import nwFiles from novelwriter.constants import nwFiles
from tests.mocked import MockApp, causeOSError from tests.mocked import MockApp, causeOSError
@@ -213,21 +214,21 @@ def testBaseConfig_Methods(fncPath):
assert tstConf.assetPath("stuff") == appPath / "assets" / "stuff" assert tstConf.assetPath("stuff") == appPath / "assets" / "stuff"
# Last Path # Last Path
assert tstConf.lastPath() == Path.home().absolute() assert tstConf.lastPath("project") == Path.home().absolute()
tmpStuff = fncPath / "stuff" tmpStuff = fncPath / "stuff"
tmpStuff.mkdir() tmpStuff.mkdir()
tstConf.setLastPath(tmpStuff) tstConf.setLastPath("project", tmpStuff)
assert tstConf.lastPath() == tmpStuff assert tstConf.lastPath("project") == tmpStuff
fileStuff = tmpStuff / "more_stuff.txt" fileStuff = tmpStuff / "more_stuff.txt"
fileStuff.write_text("Stuff") fileStuff.write_text("Stuff")
tstConf.setLastPath(fileStuff) tstConf.setLastPath("project", fileStuff)
assert tstConf.lastPath() == tmpStuff assert tstConf.lastPath("project") == tmpStuff
fileStuff.unlink() fileStuff.unlink()
tmpStuff.rmdir() tmpStuff.rmdir()
assert tstConf.lastPath() == Path.home().absolute() assert tstConf.lastPath("project") == Path.home().absolute()
# Backup Path # Backup Path
assert tstConf.backupPath() == tstConf._backPath assert tstConf.backupPath() == tstConf._backPath
@@ -440,3 +441,58 @@ def testBaseConfig_RecentCache(monkeypatch, tstPaths):
assert recent.listEntries() == [ assert recent.listEntries() == [
(str(pathOne), "Proj One", 100, 1600002000), (str(pathOne), "Proj One", 100, 1600002000),
] ]
@pytest.mark.base
def testBaseConfig_RecentPaths(monkeypatch, tstPaths):
"""Test recent paths file."""
cacheFile = tstPaths.cnfDir / nwFiles.RECENT_PATH
recent = RecentPaths(CONFIG)
# Load when there is no file should pass, but load nothing
assert not cacheFile.exists()
assert recent.loadCache() is True
assert recent._data == {}
# Set valid paths
recent.setPath("default", tstPaths.cnfDir / "default")
recent.setPath("project", tstPaths.cnfDir / "project")
recent.setPath("import", tstPaths.cnfDir / "import")
recent.setPath("outline", tstPaths.cnfDir / "outline")
recent.setPath("stats", tstPaths.cnfDir / "stats")
# Set invalid path
recent.setPath("foobar", tstPaths.cnfDir / "foobar")
# Check valid paths
assert recent.getPath("default") == str(tstPaths.cnfDir / "default")
assert recent.getPath("project") == str(tstPaths.cnfDir / "project")
assert recent.getPath("import") == str(tstPaths.cnfDir / "import")
assert recent.getPath("outline") == str(tstPaths.cnfDir / "outline")
assert recent.getPath("stats") == str(tstPaths.cnfDir / "stats")
# Check invalid path
assert recent.getPath("foobar") is None
# Check file
expected = {
"default": str(tstPaths.cnfDir / "default"),
"project": str(tstPaths.cnfDir / "project"),
"import": str(tstPaths.cnfDir / "import"),
"outline": str(tstPaths.cnfDir / "outline"),
"stats": str(tstPaths.cnfDir / "stats"),
}
assert cacheFile.exists()
assert json.loads(cacheFile.read_text()) == expected
# Clear and reload
recent._data = {}
recent.loadCache()
assert recent._data == expected
# Check error handling
with monkeypatch.context() as mp:
mp.setattr("builtins.open", causeOSError)
assert recent.saveCache() is False
assert recent.loadCache() is False
+14 -14
View File
@@ -73,29 +73,29 @@ def testCoreBuildSettings_ClassAttributes(fncPath: Path):
assert isUUID(build.buildID) assert isUUID(build.buildID)
# Last path must be valid, if not it defaults to $HOME # Last path must be valid, if not it defaults to $HOME
build.setLastPath("/path/to/nowhere") build.setLastBuildPath("/path/to/nowhere")
assert build.lastPath == CONFIG.homePath() assert build.lastBuildPath == CONFIG.homePath()
build.setLastPath(None) build.setLastBuildPath(None)
assert build.lastPath == CONFIG.homePath() assert build.lastBuildPath == CONFIG.homePath()
(fncPath / "test.txt").write_text("foobar") (fncPath / "test.txt").write_text("foobar")
build.setLastPath(fncPath / "test.txt") # Can't be a file build.setLastBuildPath(fncPath / "test.txt") # Can't be a file
assert build.lastPath == CONFIG.homePath() assert build.lastBuildPath == CONFIG.homePath()
build.setLastPath(fncPath) build.setLastBuildPath(fncPath)
assert build.lastPath == fncPath assert build.lastBuildPath == fncPath
build.setLastPath(str(fncPath)) # String paths are also ok build.setLastBuildPath(str(fncPath)) # String paths are also ok
assert build.lastPath == fncPath assert build.lastBuildPath == fncPath
# Last path no longer exists -> fallback to $HOME # Last path no longer exists -> fallback to $HOME
testDir = fncPath / "test_dir" testDir = fncPath / "test_dir"
testDir.mkdir() testDir.mkdir()
build.setLastPath(testDir) build.setLastBuildPath(testDir)
assert build.lastPath == testDir assert build.lastBuildPath == testDir
testDir.rmdir() testDir.rmdir()
assert build.lastPath == CONFIG.homePath() assert build.lastBuildPath == CONFIG.homePath()
# Last build name # Last build name
build.setLastBuildName(None) # type: ignore build.setLastBuildName(None) # type: ignore
@@ -119,7 +119,7 @@ def testCoreBuildSettings_ClassAttributes(fncPath: Path):
# Set some sensible values # Set some sensible values
build.setName("Test Build") build.setName("Test Build")
build.setBuildID("5cf45d24-f496-42c9-8733-529a9e52a62b") build.setBuildID("5cf45d24-f496-42c9-8733-529a9e52a62b")
build.setLastPath(fncPath) build.setLastBuildPath(fncPath)
build.setLastBuildName("Build Name") build.setLastBuildName("Build Name")
build.setLastFormat(nwBuildFmt.HTML) build.setLastFormat(nwBuildFmt.HTML)
+2 -2
View File
@@ -47,7 +47,7 @@ def testToolManuscriptBuild_Main(
buildTestProject(nwGUI, projPath) buildTestProject(nwGUI, projPath)
nwGUI.openProject(projPath) nwGUI.openProject(projPath)
build = BuildSettings() build = BuildSettings()
build.setLastPath(fncPath) build.setLastBuildPath(fncPath)
manus = GuiManuscriptBuild(nwGUI, build) manus = GuiManuscriptBuild(nwGUI, build)
manus.show() manus.show()
@@ -100,7 +100,7 @@ def testToolManuscriptBuild_Main(
assert build.lastBuildName == "TestBuild" assert build.lastBuildName == "TestBuild"
assert build.lastFormat == lastFmt assert build.lastFormat == lastFmt
assert build.lastPath == fncPath assert build.lastBuildPath == fncPath
# Error Handling # Error Handling
# ============== # ==============