diff --git a/locales/de.json b/locales/de.json index 47a74890..2d825a56 100644 --- a/locales/de.json +++ b/locales/de.json @@ -920,7 +920,9 @@ "dateAsc": "Älteste", "lorasCount": "LoRA-Anzahl", "lorasCountDesc": "Meiste", - "lorasCountAsc": "Wenigste" + "lorasCountAsc": "Wenigste", + "opened": "Zuletzt geöffnet", + "openedDesc": "Zuletzt geöffnet" }, "refresh": { "title": "Rezeptliste aktualisieren", diff --git a/locales/en.json b/locales/en.json index 46c809f8..b3f0a056 100644 --- a/locales/en.json +++ b/locales/en.json @@ -920,7 +920,9 @@ "dateAsc": "Oldest", "lorasCount": "LoRA Count", "lorasCountDesc": "Most", - "lorasCountAsc": "Least" + "lorasCountAsc": "Least", + "opened": "Recently Opened", + "openedDesc": "Recently opened" }, "refresh": { "title": "Refresh recipe list", diff --git a/locales/es.json b/locales/es.json index 25ddf174..338cb331 100644 --- a/locales/es.json +++ b/locales/es.json @@ -920,7 +920,9 @@ "dateAsc": "Más antiguo", "lorasCount": "Cant. de LoRAs", "lorasCountDesc": "Más", - "lorasCountAsc": "Menos" + "lorasCountAsc": "Menos", + "opened": "Abiertos recientemente", + "openedDesc": "Abiertos recientemente" }, "refresh": { "title": "Actualizar lista de recetas", diff --git a/locales/fr.json b/locales/fr.json index b67b9fbd..91a58c0e 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -920,7 +920,9 @@ "dateAsc": "Plus ancien", "lorasCount": "Nombre de LoRAs", "lorasCountDesc": "Plus", - "lorasCountAsc": "Moins" + "lorasCountAsc": "Moins", + "opened": "Récemment ouverts", + "openedDesc": "Récemment ouverts" }, "refresh": { "title": "Actualiser la liste des recipes", diff --git a/locales/he.json b/locales/he.json index de3cafd9..b5288e40 100644 --- a/locales/he.json +++ b/locales/he.json @@ -920,7 +920,9 @@ "dateAsc": "הכי ישן", "lorasCount": "מספר LoRAs", "lorasCountDesc": "הכי הרבה", - "lorasCountAsc": "הכי פחות" + "lorasCountAsc": "הכי פחות", + "opened": "נפתחו לאחרונה", + "openedDesc": "נפתחו לאחרונה" }, "refresh": { "title": "רענן רשימת מתכונים", diff --git a/locales/ja.json b/locales/ja.json index 1a9fab0a..203b8174 100644 --- a/locales/ja.json +++ b/locales/ja.json @@ -920,7 +920,9 @@ "dateAsc": "古い順", "lorasCount": "LoRA数", "lorasCountDesc": "多い順", - "lorasCountAsc": "少ない順" + "lorasCountAsc": "少ない順", + "opened": "最近開いた", + "openedDesc": "最近開いた" }, "refresh": { "title": "レシピリストを更新", diff --git a/locales/ko.json b/locales/ko.json index e6c694e7..247f10f6 100644 --- a/locales/ko.json +++ b/locales/ko.json @@ -920,7 +920,9 @@ "dateAsc": "오래된순", "lorasCount": "LoRA 수", "lorasCountDesc": "많은순", - "lorasCountAsc": "적은순" + "lorasCountAsc": "적은순", + "opened": "최근에 연", + "openedDesc": "최근에 연" }, "refresh": { "title": "레시피 목록 새로고침", diff --git a/locales/ru.json b/locales/ru.json index 0d637486..4dc8e561 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -920,7 +920,9 @@ "dateAsc": "Сначала старые", "lorasCount": "Кол-во LoRA", "lorasCountDesc": "Больше всего", - "lorasCountAsc": "Меньше всего" + "lorasCountAsc": "Меньше всего", + "opened": "Недавно открытые", + "openedDesc": "Недавно открытые" }, "refresh": { "title": "Обновить список рецептов", diff --git a/locales/zh-CN.json b/locales/zh-CN.json index a0e588eb..d6cbb712 100644 --- a/locales/zh-CN.json +++ b/locales/zh-CN.json @@ -920,7 +920,9 @@ "dateAsc": "最早", "lorasCount": "LoRA 数量", "lorasCountDesc": "最多", - "lorasCountAsc": "最少" + "lorasCountAsc": "最少", + "opened": "最近打开", + "openedDesc": "最近打开" }, "refresh": { "title": "刷新配方列表", diff --git a/locales/zh-TW.json b/locales/zh-TW.json index dc79aba7..5f17c8ba 100644 --- a/locales/zh-TW.json +++ b/locales/zh-TW.json @@ -920,7 +920,9 @@ "dateAsc": "最舊", "lorasCount": "LoRA 數量", "lorasCountDesc": "最多", - "lorasCountAsc": "最少" + "lorasCountAsc": "最少", + "opened": "最近開啟", + "openedDesc": "最近開啟" }, "refresh": { "title": "重新整理配方列表", diff --git a/py/routes/handlers/recipe_handlers.py b/py/routes/handlers/recipe_handlers.py index 3ef9de9e..609bfe3f 100644 --- a/py/routes/handlers/recipe_handlers.py +++ b/py/routes/handlers/recipe_handlers.py @@ -34,6 +34,7 @@ from ...utils.civitai_utils import ( ) from ...utils.constants import NSFW_LEVELS from ...utils.exif_utils import ExifUtils +from ...utils.recipe_open_stats import RecipeOpenStats from ...recipes.merger import GenParamsMerger from ...recipes.enrichment import RecipeEnricher from ...services.websocket_manager import ws_manager as default_ws_manager @@ -98,6 +99,7 @@ class RecipeHandlerSet: "download_shared_recipe": self.sharing.download_shared_recipe, "get_recipe_syntax": self.query.get_recipe_syntax, "update_recipe": self.management.update_recipe, + "record_recipe_open": self.management.record_recipe_open, "reconnect_lora": self.management.reconnect_lora, "find_duplicates": self.query.find_duplicates, "move_recipes_bulk": self.management.move_recipes_bulk, @@ -1458,6 +1460,33 @@ class RecipeManagementHandler: self._logger.error("Error updating recipe: %s", exc, exc_info=True) return web.json_response({"error": str(exc)}, status=500) + async def record_recipe_open(self, request: web.Request) -> web.Response: + """Record that a recipe's detail modal was opened. + + Lightweight fire-and-forget endpoint backing the "Recently Opened" + sort. It only writes the timestamp into the separate open-stats file + — recipe JSON and EXIF are never touched. + """ + try: + await self._ensure_dependencies_ready() + recipe_scanner = self._recipe_scanner_getter() + if recipe_scanner is None: + raise RuntimeError("Recipe scanner unavailable") + + recipe_id = request.match_info["recipe_id"] + # Skip recording opens for recipes the scanner no longer knows. + recipe_json_path = await recipe_scanner.get_recipe_json_path(recipe_id) + if not recipe_json_path: + return web.json_response( + {"success": False, "error": "Recipe not found"}, status=404 + ) + + RecipeOpenStats().record_open(recipe_id) + return web.json_response({"success": True}) + except Exception as exc: + self._logger.error("Error recording recipe open: %s", exc, exc_info=True) + return web.json_response({"success": False, "error": str(exc)}, status=500) + async def move_recipe(self, request: web.Request) -> web.Response: try: await self._ensure_dependencies_ready() diff --git a/py/routes/recipe_route_registrar.py b/py/routes/recipe_route_registrar.py index 17220126..3c4d3d85 100644 --- a/py/routes/recipe_route_registrar.py +++ b/py/routes/recipe_route_registrar.py @@ -43,6 +43,9 @@ ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = ( ), RouteDefinition("GET", "/api/lm/recipe/{recipe_id}/syntax", "get_recipe_syntax"), RouteDefinition("PUT", "/api/lm/recipe/{recipe_id}/update", "update_recipe"), + RouteDefinition( + "POST", "/api/lm/recipe/{recipe_id}/opened", "record_recipe_open" + ), RouteDefinition("POST", "/api/lm/recipe/move", "move_recipe"), RouteDefinition("POST", "/api/lm/recipes/move-bulk", "move_recipes_bulk"), RouteDefinition("POST", "/api/lm/recipe/lora/reconnect", "reconnect_lora"), diff --git a/py/services/recipe_scanner.py b/py/services/recipe_scanner.py index 946bbdb2..069fbb84 100644 --- a/py/services/recipe_scanner.py +++ b/py/services/recipe_scanner.py @@ -14,6 +14,7 @@ from typing import Any, Callable, Dict, Iterable, List, Optional, Set, Tuple, Un from ..config import config from ..utils.constants import VALID_CHECKPOINT_SUB_TYPES, VALID_LORA_TYPES from ..utils.file_utils import calculate_autov3 +from ..utils.recipe_open_stats import RecipeOpenStats from .recipe_cache import RecipeCache from .recipes.errors import RecipeNotFoundError, RecipePersistenceError from natsort import natsorted @@ -2782,9 +2783,11 @@ class RecipeScanner: Args: page: Current page number (1-based) page_size: Number of items per page - sort_by: Sort method ('name', 'date', 'loras_count', or 'random' - with an optional seed like 'random:abc123'; the part after - 'random:' is the shuffle seed, not a direction) + sort_by: Sort method ('name', 'date', 'loras_count', 'opened', + or 'random' with an optional seed like 'random:abc123'; the + part after 'random:' is the shuffle seed, not a direction). + 'opened' hides recipes that were never opened — it is a + "recently opened" view, not a plain reorder search: Search term filters: Dictionary of filters to apply search_options: Dictionary of search options to apply @@ -2965,7 +2968,7 @@ class RecipeScanner: ] # Apply sorting if not already handled by pre-sorted cache - if ":" in sort_by or sort_field in ("loras_count", "random"): + if ":" in sort_by or sort_field in ("loras_count", "random", "opened"): field, order = (sort_by.split(":") + ["desc"])[:2] reverse = order.lower() == "desc" @@ -2984,6 +2987,20 @@ class RecipeScanner: ), reverse=reverse, ) + elif field == "opened": + # "Recently Opened" view: recipes never opened are hidden. + # The open stats live outside recipe metadata; see + # RecipeOpenStats. + opened_map = RecipeOpenStats().get_opened_map() + filtered_data = [ + item + for item in filtered_data + if opened_map.get(str(item.get("id", ""))) is not None + ] + filtered_data.sort( + key=lambda x: opened_map.get(str(x.get("id", "")), 0), + reverse=reverse, + ) elif field == "loras_count": filtered_data.sort( key=lambda x: len(x.get("loras", [])), reverse=reverse diff --git a/py/utils/recipe_open_stats.py b/py/utils/recipe_open_stats.py new file mode 100644 index 00000000..678c478d --- /dev/null +++ b/py/utils/recipe_open_stats.py @@ -0,0 +1,161 @@ +"""Track recipe modal open timestamps for the "Recently Opened" sort. + +The data is deliberately kept OUTSIDE the recipe metadata files: recording an +open must be cheap and must never rewrite recipe JSON or EXIF (which the +generic metadata update path does). A tiny JSON map of +``recipe_id -> unix timestamp`` lives under +``{settings_dir}/stats/recipe_last_opened.json`` and is written atomically on +a short debounce. +""" + +from __future__ import annotations + +import asyncio +import json +import logging +import os +import time + +from ..utils.settings_paths import get_settings_dir + +logger = logging.getLogger(__name__) + + +class RecipeOpenStats: + """Persist the last time each recipe was opened in the recipe modal.""" + + STATS_FILENAME: str = "recipe_last_opened.json" + SAVE_DELAY: float = 1.0 # seconds of debounce between consecutive writes + + _instance: "RecipeOpenStats | None" = None + _opened: dict[str, float] + _file_mtime: float | None + _dirty: bool + _lock: asyncio.Lock + _save_task: "asyncio.Task[None] | None" + _stats_file_path: str + _initialized: bool + + def __new__(cls) -> "RecipeOpenStats": + if cls._instance is None: + cls._instance = super().__new__(cls) + cls._instance._initialized = False + return cls._instance + + def __init__(self) -> None: + if getattr(self, "_initialized", False): + return + self._opened = {} + self._file_mtime = None + self._dirty = False + self._lock = asyncio.Lock() + self._save_task = None + self._stats_file_path = self._get_stats_file_path() + self._load_stats() + self._initialized = True + + def _get_stats_file_path(self) -> str: + settings_dir = get_settings_dir(create=True) + return os.path.join(settings_dir, "stats", self.STATS_FILENAME) + + def _load_stats(self) -> None: + """Load the opened map from disk, tolerating corrupt/absent files. + + The mtime is recorded even when parsing fails so a corrupt file is + not re-read (and re-logged) on every lookup. + """ + if not os.path.exists(self._stats_file_path): + return + try: + mtime = os.path.getmtime(self._stats_file_path) + except OSError: + return + try: + with open(self._stats_file_path, "r", encoding="utf-8") as file_obj: + raw = json.load(file_obj) + if isinstance(raw, dict): + self._opened = { + str(key): float(value) + for key, value in raw.items() + if isinstance(value, (int, float)) + } + except Exception as exc: # pragma: no cover - defensive logging path + logger.error("Error loading recipe open stats: %s", exc) + self._opened = {} + self._file_mtime = mtime + + def get_opened_map(self) -> dict[str, float]: + """Return a copy of ``recipe_id -> last opened timestamp``. + + Refreshes from disk when the file changed since the last load so a + second server process (or manual edit) is picked up without restart. + """ + try: + if os.path.exists(self._stats_file_path): + mtime = os.path.getmtime(self._stats_file_path) + if self._file_mtime is None or mtime != self._file_mtime: + self._load_stats() + except OSError: + pass + return dict(self._opened) + + def record_open(self, recipe_id: str) -> None: + """Mark a recipe as opened now; persists shortly in the background.""" + if not recipe_id: + return + self._opened[str(recipe_id)] = time.time() + self._dirty = True + if self._save_task is None or self._save_task.done(): + self._save_task = asyncio.create_task(self._delayed_save()) + + async def _delayed_save(self) -> None: + """Debounced writer: batches rapid consecutive opens into one write.""" + await asyncio.sleep(self.SAVE_DELAY) + _ = await self.save_stats() + + async def save_stats(self, force: bool = False) -> bool: + """Persist the opened map atomically if dirty (or when forced). + + The on-disk map is merged in first so a second process sharing the + settings dir does not lose its entries; the larger timestamp wins + per recipe. + """ + if not force and not self._dirty: + return False + async with self._lock: + if not force and not self._dirty: + return False + try: + merged = self._merge_with_disk() + os.makedirs(os.path.dirname(self._stats_file_path), exist_ok=True) + temp_path = f"{self._stats_file_path}.tmp" + with open(temp_path, "w", encoding="utf-8") as file_obj: + json.dump(merged, file_obj, indent=2) + os.replace(temp_path, self._stats_file_path) + self._opened = merged + self._file_mtime = os.path.getmtime(self._stats_file_path) + self._dirty = False + return True + except Exception as exc: # pragma: no cover - defensive logging path + logger.error("Error saving recipe open stats: %s", exc, exc_info=True) + return False + + def _merge_with_disk(self) -> dict[str, float]: + """Merge the in-memory map with the current on-disk map.""" + disk: dict[str, float] = {} + try: + if os.path.exists(self._stats_file_path): + with open(self._stats_file_path, "r", encoding="utf-8") as file_obj: + raw = json.load(file_obj) + if isinstance(raw, dict): + disk = { + str(key): float(value) + for key, value in raw.items() + if isinstance(value, (int, float)) + } + except Exception as exc: # pragma: no cover - defensive logging path + logger.error("Error reading recipe open stats for merge: %s", exc) + merged = dict(disk) + for key, value in self._opened.items(): + merged[key] = max(value, disk.get(key, 0.0)) + return merged diff --git a/static/js/components/RecipeModal.js b/static/js/components/RecipeModal.js index 18e31544..f90c7d65 100644 --- a/static/js/components/RecipeModal.js +++ b/static/js/components/RecipeModal.js @@ -306,6 +306,14 @@ class RecipeModal { modalManager.showModal('recipeModal'); if (this.recipeId) { + // Fire-and-forget: record this open for the "Recently Opened" + // sort. Tracking must never disturb the modal, so failures are + // swallowed. + fetch(`/api/lm/recipe/${encodeURIComponent(this.recipeId)}/opened`, { + method: 'POST', + keepalive: true, + }).catch(() => {}); + const hydrationRequestId = ++this.recipeHydrationRequestId; const requestEditVersions = this.captureLocalEditVersions(); this.hydrateRecipeDetails( diff --git a/static/js/utils/MasonryScroller.js b/static/js/utils/MasonryScroller.js index 352be07f..b8a57bf7 100644 --- a/static/js/utils/MasonryScroller.js +++ b/static/js/utils/MasonryScroller.js @@ -646,10 +646,17 @@ export class MasonryScroller { const pageType = state.currentPageType; if (pageType === 'recipes') { - placeholderText = ` -

No recipes found

-

Add recipe images to your recipes folder to see them here.

- `; + if (String(getCurrentPageState().sortBy).startsWith('opened')) { + placeholderText = ` +

No recently opened recipes

+

Recipes you open will appear here.

+ `; + } else { + placeholderText = ` +

No recipes found

+

Add recipe images to your recipes folder to see them here.

+ `; + } } else if (pageType === 'loras') { placeholderText = `

No LoRAs found

diff --git a/static/js/utils/VirtualScroller.js b/static/js/utils/VirtualScroller.js index 22b5e00d..ac3b9656 100644 --- a/static/js/utils/VirtualScroller.js +++ b/static/js/utils/VirtualScroller.js @@ -699,10 +699,17 @@ export class VirtualScroller { const pageType = state.currentPageType; if (pageType === 'recipes') { - placeholderText = ` -

No recipes found

-

Add recipe images to your recipes folder to see them here.

- `; + if (String(getCurrentPageState().sortBy).startsWith('opened')) { + placeholderText = ` +

No recently opened recipes

+

Recipes you open will appear here.

+ `; + } else { + placeholderText = ` +

No recipes found

+

Add recipe images to your recipes folder to see them here.

+ `; + } } else if (pageType === 'loras') { placeholderText = `

No LoRAs found

diff --git a/templates/components/controls.html b/templates/components/controls.html index cf3f0cad..b88055bb 100644 --- a/templates/components/controls.html +++ b/templates/components/controls.html @@ -54,6 +54,11 @@ {% endif %} + {% if page_id == 'recipes' %} + + + + {% endif %} diff --git a/tests/frontend/utils/masonryScroller.test.js b/tests/frontend/utils/masonryScroller.test.js index d6c29702..2ec3677e 100644 --- a/tests/frontend/utils/masonryScroller.test.js +++ b/tests/frontend/utils/masonryScroller.test.js @@ -324,6 +324,18 @@ describe('MasonryScroller', () => { expect(placeholder.textContent).toContain('No recipes found'); }); + it('shows the recently-opened empty placeholder under the opened sort', async () => { + getCurrentPageState().sortBy = 'opened:desc'; + const { scroller, grid } = track(createScroller({ items: [] })); + + await scroller.initialize(); + + const placeholder = grid.querySelector('#virtualScrollPlaceholder'); + expect(placeholder).not.toBeNull(); + expect(placeholder.textContent).toContain('No recently opened recipes'); + getCurrentPageState().sortBy = ''; + }); + it('dispose removes classes, spacer and event listeners', () => { const { scroller, grid } = track(createScroller()); diff --git a/tests/services/test_recipe_scanner.py b/tests/services/test_recipe_scanner.py index 80e247ae..133d596e 100644 --- a/tests/services/test_recipe_scanner.py +++ b/tests/services/test_recipe_scanner.py @@ -1095,6 +1095,58 @@ async def test_get_paginated_data_random_sort(recipe_scanner): assert len(set(combined)) == 3 +@pytest.mark.asyncio +async def test_get_paginated_data_opened_sort(recipe_scanner, monkeypatch): + scanner, _ = recipe_scanner + + for rid, title in [("A", "Alpha"), ("B", "Beta"), ("C", "Gamma")]: + await scanner.add_recipe( + { + "id": rid, + "title": title, + "created_date": 10.0, + "loras": [{}], + "file_path": f"{rid.lower()}.png", + } + ) + + await asyncio.sleep(0) + await _wait_for_resort(scanner) + + class _FakeStats: + def get_opened_map(self): + return {"B": 300.0, "C": 200.0} + + monkeypatch.setattr( + "py.services.recipe_scanner.RecipeOpenStats", lambda: _FakeStats() + ) + + # Never-opened A is hidden from the view; B (300) > C (200) + res = await scanner.get_paginated_data(page=1, page_size=10, sort_by="opened:desc") + assert [i["id"] for i in res["items"]] == ["B", "C"] + assert res["total"] == 2 + + # ASC: C (200) < B (300) + res = await scanner.get_paginated_data(page=1, page_size=10, sort_by="opened:asc") + assert [i["id"] for i in res["items"]] == ["C", "B"] + + # Plain "opened" (no direction) behaves like desc by default + res = await scanner.get_paginated_data(page=1, page_size=10, sort_by="opened") + assert [i["id"] for i in res["items"]] == ["B", "C"] + + # When nothing was opened the view is empty (not a fallback reorder) + class _EmptyStats: + def get_opened_map(self): + return {} + + monkeypatch.setattr( + "py.services.recipe_scanner.RecipeOpenStats", lambda: _EmptyStats() + ) + res = await scanner.get_paginated_data(page=1, page_size=10, sort_by="opened:desc") + assert res["items"] == [] + assert res["total"] == 0 + + async def test_build_image_id_map_filters_correctly(recipe_scanner): """Only recipes with valid CivitAI source_path appear in image_id_map. diff --git a/tests/utils/test_recipe_open_stats.py b/tests/utils/test_recipe_open_stats.py new file mode 100644 index 00000000..8f283638 --- /dev/null +++ b/tests/utils/test_recipe_open_stats.py @@ -0,0 +1,159 @@ +import asyncio +import contextlib +import json +from pathlib import Path + +import pytest + +from py.utils import recipe_open_stats as stats_module +from py.utils.recipe_open_stats import RecipeOpenStats + + +async def _finalize(tasks) -> None: + for task in tasks: + task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await task + RecipeOpenStats._instance = None + + +def _prepare(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + RecipeOpenStats._instance = None + settings_dir = tmp_path / "settings" + settings_dir.mkdir(parents=True, exist_ok=True) + monkeypatch.setattr( + stats_module, "get_settings_dir", lambda create=True: str(settings_dir) + ) + created_tasks = [] + real_create_task = stats_module.asyncio.create_task + + def _track_task(coro): + task = real_create_task(coro) + created_tasks.append(task) + return task + + monkeypatch.setattr(stats_module.asyncio, "create_task", _track_task) + return RecipeOpenStats(), created_tasks, settings_dir + + +async def _wait_for_save(stats_file: Path) -> None: + for _ in range(100): + if stats_file.exists(): + return + await asyncio.sleep(0.01) + raise AssertionError("Recipe open stats file was never written") + + +@pytest.mark.asyncio +async def test_record_open_persists_timestamp(tmp_path, monkeypatch): + stats, tasks, settings_dir = _prepare(tmp_path, monkeypatch) + stats_file = settings_dir / "stats" / RecipeOpenStats.STATS_FILENAME + + stats.record_open("abc-123") + await _wait_for_save(stats_file) + + data = json.loads(stats_file.read_text(encoding="utf-8")) + assert isinstance(data["abc-123"], float) + await _finalize(tasks) + + +@pytest.mark.asyncio +async def test_record_open_updates_existing_entry(tmp_path, monkeypatch): + stats, tasks, settings_dir = _prepare(tmp_path, monkeypatch) + stats_file = settings_dir / "stats" / RecipeOpenStats.STATS_FILENAME + + stats.record_open("r1") + await _wait_for_save(stats_file) + first = json.loads(stats_file.read_text(encoding="utf-8"))["r1"] + + await asyncio.sleep(0.01) + stats.record_open("r1") + await stats.save_stats(force=True) + + second = json.loads(stats_file.read_text(encoding="utf-8"))["r1"] + assert second > first + await _finalize(tasks) + + +@pytest.mark.asyncio +async def test_get_opened_map_reloads_on_file_change(tmp_path, monkeypatch): + stats, tasks, settings_dir = _prepare(tmp_path, monkeypatch) + stats_file = settings_dir / "stats" / RecipeOpenStats.STATS_FILENAME + + stats.record_open("r1") + await _wait_for_save(stats_file) + + stats_file.write_text(json.dumps({"r2": 500.0}), encoding="utf-8") + opened_map = stats.get_opened_map() + assert opened_map == {"r2": 500.0} + await _finalize(tasks) + + +@pytest.mark.asyncio +async def test_save_merges_entries_written_by_another_process(tmp_path, monkeypatch): + stats, tasks, settings_dir = _prepare(tmp_path, monkeypatch) + stats_file = settings_dir / "stats" / RecipeOpenStats.STATS_FILENAME + + stats.record_open("r1") + await _wait_for_save(stats_file) + first_ts = json.loads(stats_file.read_text(encoding="utf-8"))["r1"] + + # Another process writes its own entry plus a newer timestamp for r1 + stats_file.write_text( + json.dumps({"r1": first_ts + 100000.0, "r2": 500.0}), encoding="utf-8" + ) + + stats.record_open("r3") + await stats.save_stats(force=True) + + data = json.loads(stats_file.read_text(encoding="utf-8")) + # r2 from the other process survives; r1 keeps the newer disk timestamp; + # r3 from this process is added + assert data["r1"] == first_ts + 100000.0 + assert data["r2"] == 500.0 + assert isinstance(data["r3"], float) + await _finalize(tasks) + + +@pytest.mark.asyncio +async def test_get_opened_map_returns_copy(tmp_path, monkeypatch): + stats, tasks, _ = _prepare(tmp_path, monkeypatch) + stats.record_open("r1") + + opened_map = stats.get_opened_map() + opened_map["injected"] = 1.0 + assert "injected" not in stats.get_opened_map() + await _finalize(tasks) + + +@pytest.mark.asyncio +async def test_missing_stats_file_returns_empty_map(tmp_path, monkeypatch): + stats, tasks, _ = _prepare(tmp_path, monkeypatch) + assert stats.get_opened_map() == {} + await _finalize(tasks) + + +@pytest.mark.asyncio +async def test_save_stats_skips_when_not_dirty(tmp_path, monkeypatch): + stats, tasks, settings_dir = _prepare(tmp_path, monkeypatch) + stats_file = settings_dir / "stats" / RecipeOpenStats.STATS_FILENAME + + assert await stats.save_stats() is False + assert not stats_file.exists() + await _finalize(tasks) + + +@pytest.mark.asyncio +async def test_load_ignores_corrupt_file(tmp_path, monkeypatch): + settings_dir = tmp_path / "settings" + settings_dir.mkdir(parents=True, exist_ok=True) + stats_file = settings_dir / "stats" / RecipeOpenStats.STATS_FILENAME + stats_file.parent.mkdir(parents=True, exist_ok=True) + stats_file.write_text("{not valid json", encoding="utf-8") + + monkeypatch.setattr( + stats_module, "get_settings_dir", lambda create=True: str(settings_dir) + ) + RecipeOpenStats._instance = None + stats = RecipeOpenStats() + assert stats.get_opened_map() == {}