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