Remember last used path for each tool individually (#1934)
This commit is contained in:
+67
-15
@@ -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 2018–2024, 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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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", ""))
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
# ==============
|
||||
|
||||
Reference in New Issue
Block a user