diff --git a/novelwriter/config.py b/novelwriter/config.py index 148e75d4..d122a0b1 100644 --- a/novelwriter/config.py +++ b/novelwriter/config.py @@ -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 diff --git a/novelwriter/constants.py b/novelwriter/constants.py index bf62caeb..5186a5d1 100644 --- a/novelwriter/constants.py +++ b/novelwriter/constants.py @@ -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" diff --git a/novelwriter/core/buildsettings.py b/novelwriter/core/buildsettings.py index 72fd395b..3ceee536 100644 --- a/novelwriter/core/buildsettings.py +++ b/novelwriter/core/buildsettings.py @@ -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", "")) diff --git a/novelwriter/gui/outline.py b/novelwriter/gui/outline.py index d404794a..aa3d756d 100644 --- a/novelwriter/gui/outline.py +++ b/novelwriter/gui/outline.py @@ -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] diff --git a/novelwriter/guimain.py b/novelwriter/guimain.py index 6fd1e1be..ed4b1650 100644 --- a/novelwriter/guimain.py +++ b/novelwriter/guimain.py @@ -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." diff --git a/novelwriter/tools/manusbuild.py b/novelwriter/tools/manusbuild.py index a8f2f7c2..d87b9be2 100644 --- a/novelwriter/tools/manusbuild.py +++ b/novelwriter/tools/manusbuild.py @@ -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) diff --git a/novelwriter/tools/welcome.py b/novelwriter/tools/welcome.py index 3f47b707..b962e98c 100644 --- a/novelwriter/tools/welcome.py +++ b/novelwriter/tools/welcome.py @@ -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() diff --git a/novelwriter/tools/writingstats.py b/novelwriter/tools/writingstats.py index 8e42a0c0..864ad177 100644 --- a/novelwriter/tools/writingstats.py +++ b/novelwriter/tools/writingstats.py @@ -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 diff --git a/tests/conftest.py b/tests/conftest.py index 5ea7cbb2..c8abbfda 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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) diff --git a/tests/reference/baseConfig_novelwriter.conf b/tests/reference/baseConfig_novelwriter.conf index 0554bd6d..b9d485a2 100644 --- a/tests/reference/baseConfig_novelwriter.conf +++ b/tests/reference/baseConfig_novelwriter.conf @@ -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 diff --git a/tests/test_base/test_base_config.py b/tests/test_base/test_base_config.py index 93a09a75..c5ca9b72 100644 --- a/tests/test_base/test_base_config.py +++ b/tests/test_base/test_base_config.py @@ -20,6 +20,7 @@ along with this program. If not, see . """ 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 diff --git a/tests/test_core/test_core_buildsettings.py b/tests/test_core/test_core_buildsettings.py index d08bd6c4..b1308ea7 100644 --- a/tests/test_core/test_core_buildsettings.py +++ b/tests/test_core/test_core_buildsettings.py @@ -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) diff --git a/tests/test_tools/test_tools_manusbuild.py b/tests/test_tools/test_tools_manusbuild.py index eb1685aa..b6e22edc 100644 --- a/tests/test_tools/test_tools_manusbuild.py +++ b/tests/test_tools/test_tools_manusbuild.py @@ -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 # ==============