From f686ec6a5a5f5dd9e993b0b4f7a962a4b4e65322 Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Thu, 30 Jan 2025 23:11:10 +0100 Subject: [PATCH 1/3] Save recent projects by their ID instead of path (#2217) --- novelwriter/common.py | 2 +- novelwriter/config.py | 53 +++++++++++++++++++++++-------------- novelwriter/core/project.py | 8 ++---- 3 files changed, 36 insertions(+), 27 deletions(-) diff --git a/novelwriter/common.py b/novelwriter/common.py index 317aced5a..7587b2ea0 100644 --- a/novelwriter/common.py +++ b/novelwriter/common.py @@ -110,7 +110,7 @@ def checkBool(value: Any, default: bool) -> bool: return default -def checkUuid(value: Any, default: str) -> str: +def checkUuid(value: Any, default: str = "") -> str: """Try to process a value as an UUID, or return a default.""" try: return str(uuid.UUID(value)) diff --git a/novelwriter/config.py b/novelwriter/config.py index bfcc73a72..95efdc557 100644 --- a/novelwriter/config.py +++ b/novelwriter/config.py @@ -32,6 +32,7 @@ from datetime import datetime from pathlib import Path from time import time +from typing import TYPE_CHECKING from PyQt5.QtCore import ( PYQT_VERSION, PYQT_VERSION_STR, QT_VERSION, QT_VERSION_STR, QLibraryInfo, @@ -47,6 +48,9 @@ from novelwriter.constants import nwFiles, nwUnicode from novelwriter.error import formatException, logException +if TYPE_CHECKING: # pragma: no cover + from novelwriter.core.projectdata import NWProjectData + logger = logging.getLogger(__name__) @@ -845,29 +849,31 @@ class RecentProjects: def __init__(self, config: Config) -> None: self._conf = config - self._data = {} + self._data: dict[str, dict[str, str | int]] = {} + self._map: dict[str, str] = {} return def loadCache(self) -> bool: """Load the cache file for recent projects.""" self._data = {} - + self._map = {} cacheFile = self._conf.dataPath(nwFiles.RECENT_FILE) if cacheFile.is_file(): try: with open(cacheFile, mode="r", encoding="utf-8") as inFile: data = json.load(inFile) - for path, entry in data.items(): - self._data[path] = { - "title": entry.get("title", ""), - "words": entry.get("words", 0), - "time": entry.get("time", 0), - } + for key, entry in data.items(): + path = str(entry.get("path", key)) + title = str(entry.get("title", "")) + words = checkInt(entry.get("words", 0), 0) + saved = checkInt(entry.get("time", 0), 0) + if path and title: + self._setEntry(key, path, title, words, saved) + self._map[path] = key except Exception: logger.error("Could not load recent project cache") logException() return False - return True def saveCache(self) -> bool: @@ -882,33 +888,40 @@ def saveCache(self) -> bool: logger.error("Could not save recent project cache") logException() return False - return True def listEntries(self) -> list[tuple[str, str, int, int]]: """List all items in the cache.""" return [ - (str(k), str(e["title"]), checkInt(e["words"], 0), checkInt(e["time"], 0)) - for k, e in self._data.items() + (str(e["path"]), str(e["title"]), checkInt(e["words"], 0), checkInt(e["time"], 0)) + for e in self._data.values() ] - def update(self, path: str | Path, title: str, words: int, saved: float | int) -> None: + def update(self, path: str | Path, data: NWProjectData, saved: float | int) -> None: """Add or update recent cache information on a given project.""" - self._data[str(path)] = { - "title": title, - "words": int(words), - "time": int(saved), - } - self.saveCache() + try: + self.remove(path) + self._setEntry(data.uuid, str(path), data.name, sum(data.currCounts), int(saved)) + self.saveCache() + except Exception: + pass return def remove(self, path: str | Path) -> None: """Try to remove a path from the recent projects cache.""" - if self._data.pop(str(path), None) is not None: + if remove := self._map.get(str(path)): + self._data.pop(remove, None) + self._map.pop(str(path), None) logger.debug("Removed recent: %s", path) self.saveCache() return + def _setEntry(self, key: str, path: str, title: str, words: int, saved: int) -> None: + """Set an entry in the projects list.""" + self._data[key] = {"path": path, "title": title, "words": words, "time": saved} + self._map[path] = key + return + class RecentPaths: diff --git a/novelwriter/core/project.py b/novelwriter/core/project.py index e568e1cc4..f16cd49df 100644 --- a/novelwriter/core/project.py +++ b/novelwriter/core/project.py @@ -353,9 +353,7 @@ def openProject(self, projPath: str | Path, clearLock: bool = False) -> bool: # Update recent projects if storePath := self._storage.storagePath: - CONFIG.recentProjects.update( - storePath, self._data.name, sum(self._data.initCounts), time() - ) + CONFIG.recentProjects.update(storePath, self._data, time()) # Check the project tree consistency # This also handles any orphaned files found @@ -421,9 +419,7 @@ def saveProject(self, autoSave: bool = False) -> bool: # Update recent projects if storagePath := self._storage.storagePath: - CONFIG.recentProjects.update( - storagePath, self._data.name, sum(self._data.currCounts), saveTime - ) + CONFIG.recentProjects.update(storagePath, self._data, saveTime) SHARED.newStatusMessage(self.tr("Saved Project: {0}").format(self._data.name)) self.setProjectChanged(False) From dcc25dac3153632377bd5c923f9239f596ce34ba Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Thu, 30 Jan 2025 23:11:21 +0100 Subject: [PATCH 2/3] Update tests --- tests/test_base/test_base_config.py | 25 ++++++++++++++++++++++--- tests/test_tools/test_tools_welcome.py | 15 +++++++++++++-- 2 files changed, 35 insertions(+), 5 deletions(-) diff --git a/tests/test_base/test_base_config.py b/tests/test_base/test_base_config.py index 864f9910e..46d406341 100644 --- a/tests/test_base/test_base_config.py +++ b/tests/test_base/test_base_config.py @@ -31,6 +31,7 @@ from novelwriter import CONFIG from novelwriter.config import Config, RecentPaths, RecentProjects from novelwriter.constants import nwFiles +from novelwriter.core.project import NWProject from tests.mocked import MockApp, causeOSError from tests.tools import cmpFiles, writeFile @@ -382,7 +383,7 @@ def testBaseConfig_Internal(monkeypatch, fncPath): @pytest.mark.base -def testBaseConfig_RecentCache(monkeypatch, tstPaths): +def testBaseConfig_RecentCache(monkeypatch, tstPaths, nwGUI): """Test recent cache file.""" cacheFile = tstPaths.cnfDir / nwFiles.RECENT_FILE recent = RecentProjects(CONFIG) @@ -396,8 +397,18 @@ def testBaseConfig_RecentCache(monkeypatch, tstPaths): pathOne = tstPaths.cnfDir / "projPathOne" / nwFiles.PROJ_FILE pathTwo = tstPaths.cnfDir / "projPathTwo" / nwFiles.PROJ_FILE - recent.update(pathOne, "Proj One", 100, 1600002000) - recent.update(pathTwo, "Proj Two", 200, 1600005600) + prjOne = NWProject() + prjTwo = NWProject() + + prjOne.data.setUuid(None) + prjTwo.data.setUuid(None) + prjOne.data.setName("Proj One") + prjTwo.data.setName("Proj Two") + prjOne.data.setCurrCounts(100, 0) + prjTwo.data.setCurrCounts(200, 0) + + recent.update(pathOne, prjOne.data, 1600002000) + recent.update(pathTwo, prjTwo.data, 1600005600) assert recent.listEntries() == [ (str(pathOne), "Proj One", 100, 1600002000), (str(pathTwo), "Proj Two", 200, 1600005600), @@ -429,6 +440,14 @@ def testBaseConfig_RecentCache(monkeypatch, tstPaths): (str(pathTwo), "Proj Two", 200, 1600005600), ] + # Pass Invalid + recent.update(None, None, 0) # type: ignore + recent.update(None, None, 0) # type: ignore + assert recent.listEntries() == [ + (str(pathOne), "Proj One", 100, 1600002000), + (str(pathTwo), "Proj Two", 200, 1600005600), + ] + # Remove Non-Existent Entry recent.remove("stuff") assert recent.listEntries() == [ diff --git a/tests/test_tools/test_tools_welcome.py b/tests/test_tools/test_tools_welcome.py index e827d0cbd..6e199dc6b 100644 --- a/tests/test_tools/test_tools_welcome.py +++ b/tests/test_tools/test_tools_welcome.py @@ -31,6 +31,7 @@ from novelwriter import CONFIG, SHARED from novelwriter.constants import nwFiles +from novelwriter.core.projectdata import NWProjectData from novelwriter.enum import nwItemClass from novelwriter.tools.welcome import GuiWelcome from novelwriter.types import QtMouseLeft @@ -72,8 +73,18 @@ def testToolWelcome_Open(qtbot: QtBot, monkeypatch, nwGUI, fncPath): monkeypatch.setattr(QMenu, "exec", lambda *a: None) monkeypatch.setattr(QMenu, "setParent", lambda *a: None) - CONFIG.recentProjects.update("/stuff/project_one", "Project One", 12345, 1690000000) - CONFIG.recentProjects.update("/stuff/project_two", "Project Two", 54321, 1700000000) + data1 = NWProjectData(SHARED.project) + data2 = NWProjectData(SHARED.project) + + data1.setUuid(None) + data2.setUuid(None) + data1.setName("Project One") + data2.setName("Project Two") + data1.setCurrCounts(12345, 0) + data2.setCurrCounts(54321, 0) + + CONFIG.recentProjects.update("/stuff/project_one", data1, 1690000000) + CONFIG.recentProjects.update("/stuff/project_two", data2, 1700000000) dateOne = CONFIG.localDate(datetime.fromtimestamp(1700000000)) dateTwo = CONFIG.localDate(datetime.fromtimestamp(1690000000)) From 247702babe3bace294e44b94168086de63aba318 Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Thu, 30 Jan 2025 23:59:02 +0100 Subject: [PATCH 3/3] Don't break recent projects file compatibility --- novelwriter/common.py | 2 +- novelwriter/config.py | 27 +++++++++++++-------------- 2 files changed, 14 insertions(+), 15 deletions(-) diff --git a/novelwriter/common.py b/novelwriter/common.py index 7587b2ea0..317aced5a 100644 --- a/novelwriter/common.py +++ b/novelwriter/common.py @@ -110,7 +110,7 @@ def checkBool(value: Any, default: bool) -> bool: return default -def checkUuid(value: Any, default: str = "") -> str: +def checkUuid(value: Any, default: str) -> str: """Try to process a value as an UUID, or return a default.""" try: return str(uuid.UUID(value)) diff --git a/novelwriter/config.py b/novelwriter/config.py index 95efdc557..2a83da598 100644 --- a/novelwriter/config.py +++ b/novelwriter/config.py @@ -862,14 +862,13 @@ def loadCache(self) -> bool: try: with open(cacheFile, mode="r", encoding="utf-8") as inFile: data = json.load(inFile) - for key, entry in data.items(): - path = str(entry.get("path", key)) + for path, entry in data.items(): + puuid = str(entry.get("uuid", "")) title = str(entry.get("title", "")) words = checkInt(entry.get("words", 0), 0) saved = checkInt(entry.get("time", 0), 0) if path and title: - self._setEntry(key, path, title, words, saved) - self._map[path] = key + self._setEntry(puuid, path, title, words, saved) except Exception: logger.error("Could not load recent project cache") logException() @@ -893,14 +892,15 @@ def saveCache(self) -> bool: def listEntries(self) -> list[tuple[str, str, int, int]]: """List all items in the cache.""" return [ - (str(e["path"]), str(e["title"]), checkInt(e["words"], 0), checkInt(e["time"], 0)) - for e in self._data.values() + (str(k), str(e["title"]), checkInt(e["words"], 0), checkInt(e["time"], 0)) + for k, e in self._data.items() ] def update(self, path: str | Path, data: NWProjectData, saved: float | int) -> None: """Add or update recent cache information on a given project.""" try: - self.remove(path) + if (remove := self._map.get(data.uuid)) and (remove != str(path)): + self.remove(remove) self._setEntry(data.uuid, str(path), data.name, sum(data.currCounts), int(saved)) self.saveCache() except Exception: @@ -909,17 +909,16 @@ def update(self, path: str | Path, data: NWProjectData, saved: float | int) -> N def remove(self, path: str | Path) -> None: """Try to remove a path from the recent projects cache.""" - if remove := self._map.get(str(path)): - self._data.pop(remove, None) - self._map.pop(str(path), None) + if self._data.pop(str(path), None) is not None: logger.debug("Removed recent: %s", path) self.saveCache() return - def _setEntry(self, key: str, path: str, title: str, words: int, saved: int) -> None: - """Set an entry in the projects list.""" - self._data[key] = {"path": path, "title": title, "words": words, "time": saved} - self._map[path] = key + def _setEntry(self, puuid: str, path: str, title: str, words: int, saved: int) -> None: + """Set an entry in the recent projects record.""" + self._data[path] = {"uuid": puuid, "title": title, "words": words, "time": saved} + if puuid: + self._map[puuid] = path return