diff --git a/CHANGELOG.md b/CHANGELOG.md index 1afa4095..f136d44a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,13 @@ accepts both, but the store flags the old spelling as deprecated app's location in the web UI now actually clears it; the save used to drop the blank field, so the old value stayed. +- The web UI's Fonts tab has a **Used by** column: the loaded plugins that + registered each font with `FontManager.register_manager_font()`, published + by the display service to the shared cache (`src/font_usage.py`) and merged + into `GET /api/v3/fonts/catalog` as `used_by`. Deleting a font a plugin + uses now names those plugins in the confirmation (it is not blocked). + `FontManager.forget_manager_fonts()` is new; unloading a plugin calls it. + Deprecated, removed in 3.7.0 (each logs a warning on first use; see `docs/PLUGIN_API_REFERENCE.md#deprecated-apis` for replacements). Nothing in core, the monorepo or the registry's third-party plugins calls them: diff --git a/docs/FONT_MANAGER.md b/docs/FONT_MANAGER.md index 1acf32f1..0d552687 100644 --- a/docs/FONT_MANAGER.md +++ b/docs/FONT_MANAGER.md @@ -173,8 +173,10 @@ font = self.font_manager.resolve_font( > install directory. > > The web UI's **Fonts** tab lists, uploads, previews and deletes the -> font files in `assets/fonts/`. It does not show fonts registered -> through `register_manager_font()` and has no override editor (the +> font files in `assets/fonts/`. Its **Used by** column shows which +> loaded plugins registered each file through `register_manager_font()` +> (see [Font usage in the web UI](#font-usage-in-the-web-ui)), and it +> warns before deleting one of them. It has no override editor (the > override panels and `/api/v3/fonts/overrides` endpoints were removed). > The programmatic override workflow in > [Manual Font Overrides](#manual-font-overrides) below still works. @@ -276,6 +278,31 @@ Place font files in `assets/fonts/` directory: - Font family name is derived from filename (without extension) - Will be automatically discovered on next initialization +## Font usage in the web UI + +The web interface runs in its own process and has no FontManager, so the +display service publishes which plugin uses which font +(`src/font_usage.py`), and the Fonts tab's **Used by** column reads it: + +- **Source**: `register_manager_font()` registrations of the loaded + plugins. `get_font()` and `resolve_font()` do not know the calling plugin + and are not counted, and neither is a plugin that opens a font file + directly with PIL — register the fonts your plugin draws with if you want + them listed. +- **Names**: a family, alias (`press_start`, `four_by_six`, + `five_by_seven`, `tom_thumb`) or path is resolved through + `font_catalog` to the file it loads and reported under that file's name + without extension (`PressStart2P-Regular`, `4x6-font`, `5x7`, + `tom-thumb`), which is how the Fonts tab keys its rows. Fonts outside + `assets/fonts/` (a plugin's own `plugin_id::family` fonts) and families + that resolve to nothing are left out. +- **When**: a daemon thread started once plugins have loaded checks every + 10 seconds and writes the `font_usage_snapshot` cache key only when the + usage changed (and once a day, so the cache's cleanup never expires it). + Unloading a plugin drops its registrations (`forget_manager_fonts`). +- **Unknown**: until the display service has published, the column reads + "unknown" and `GET /api/v3/fonts/catalog` returns `used_by: null`. + ## Performance Monitoring ```python @@ -374,6 +401,7 @@ self.font = self.font_manager.resolve_font( ### FontManager Methods - `register_manager_font(manager_id, element_key, family, size_px, color=None)` - Register font usage +- `forget_manager_fonts(manager_id)` - Drop a manager's registrations (core calls it when a plugin unloads) - `resolve_font(element_key, family, size_px, plugin_id=None)` - Get font with override support - `get_font(family, size_px)` - Get font directly (bypasses overrides) - `measure_text(text, font)` - Measure text dimensions diff --git a/docs/REST_API_REFERENCE.md b/docs/REST_API_REFERENCE.md index 2c8f1e67..8406403e 100644 --- a/docs/REST_API_REFERENCE.md +++ b/docs/REST_API_REFERENCE.md @@ -1489,23 +1489,37 @@ Delete a stored backup. Fonts in `assets/fonts/`, keyed by file name without extension. +`used_by` lists the plugins that use the font, as the display service last +reported: the loaded plugins that registered it through +`FontManager.register_manager_font()`, under whatever family, alias +(`press_start`, `four_by_six`, ...) or path they gave. It is `[]` when no +loaded plugin registered it and `null` when the display service has not +reported yet (`font_usage.available` is then `false`). A plugin that opens a +font file directly is not counted, so `[]` does not prove a font is unused. +The file scan is cached for 5 minutes; `used_by` is read on every request. + **Response**: ```json { "status": "success", "data": { "catalog": { - "press_start": { - "filename": "press_start.ttf", + "PressStart2P-Regular": { + "filename": "PressStart2P-Regular.ttf", "family_name": "Press Start 2P", "display_name": "Press Start 2P", - "path": "assets/fonts/press_start.ttf", + "path": "assets/fonts/PressStart2P-Regular.ttf", "type": "ttf", "is_system": true, "scalable": true, "native_size": null, - "metadata": { ... } + "metadata": { ... }, + "used_by": ["calendar", "hello-world"] } + }, + "font_usage": { + "available": true, + "generated_at": "2026-09-23T10:00:00" } } } @@ -1560,7 +1574,9 @@ Upload a custom font file. It is saved as `assets/fonts/`. **DELETE** `/api/v3/fonts/` Delete an uploaded font (`` is the file name without -extension). System fonts answer `403`. +extension). System fonts answer `403`. A font a plugin uses is not refused; +the Fonts tab names those plugins (the catalog's `used_by`) in its +confirmation first. ### Font Preview diff --git a/src/display_controller.py b/src/display_controller.py index 1385cb82..42201c73 100644 --- a/src/display_controller.py +++ b/src/display_controller.py @@ -406,6 +406,11 @@ class DisplayController: logger.exception("Plugin system initialization failed") self.plugin_manager = None + # The web UI's Fonts tab ("Used by") reads what this publishes. + from src.font_usage import start_font_usage_publisher + self._font_usage_publisher = start_font_usage_publisher( + self.cache_manager, self.font_manager, self.plugin_manager) + # Display rotation state self.current_mode_index = 0 self.current_display_mode = None @@ -3341,6 +3346,8 @@ class DisplayController: self.config_service.shutdown() except Exception as e: logger.warning("Error shutting down config service: %s", e) + if getattr(self, '_font_usage_publisher', None) is not None: + self._font_usage_publisher.stop() logger.info("Cleaning up display controller...") if hasattr(self, 'display_manager'): self.display_manager.cleanup() diff --git a/src/font_manager.py b/src/font_manager.py index a6927c1e..eb6f72b1 100644 --- a/src/font_manager.py +++ b/src/font_manager.py @@ -78,6 +78,10 @@ class FontManager: # Manager font registration - NEW for manager-centric model self.manager_fonts: Dict[str, Dict[str, Any]] = {} # manager_id -> {element_key: {family, size_px, color}} self.detected_fonts: Dict[str, Dict[str, Any]] = {} # element_key -> {family, size_px, color, manager_id, usage_count} + # Bumped when a manager's registered families change (not when one + # re-registers what it already had), so src/font_usage.py can tell + # "nothing new" without rebuilding its snapshot. + self.manager_fonts_version = 0 # Dynamic font loading self.temp_font_dir = Path(tempfile.gettempdir()) / "ledmatrix_fonts" @@ -156,7 +160,10 @@ class FontManager: } if color: font_spec["color"] = color - + + previous = self.manager_fonts[manager_id].get(element_key) + if previous is None or previous.get("family") != family: + self.manager_fonts_version += 1 self.manager_fonts[manager_id][element_key] = font_spec # Track usage in detected_fonts @@ -168,6 +175,17 @@ class FontManager: logger.debug(f"Registered font for {manager_id}.{element_key}: {family}@{size_px}px") + def forget_manager_fonts(self, manager_id: str) -> None: + """Drop every registration ``manager_id`` made. Called by core when a + plugin is unloaded, so a reloaded plugin starts from what its new + instance registers and the web UI's Fonts tab stops listing it.""" + removed = self.manager_fonts.pop(manager_id, None) + for element_key, spec in list(self.detected_fonts.items()): + if spec.get("manager_id") == manager_id: + self.detected_fonts.pop(element_key, None) + if removed: + self.manager_fonts_version += 1 + @deprecated("3.7.0") def get_manager_fonts(self, manager_id: Optional[str] = None) -> Dict[str, Any]: """ diff --git a/src/font_usage.py b/src/font_usage.py new file mode 100644 index 00000000..646e6b5b --- /dev/null +++ b/src/font_usage.py @@ -0,0 +1,272 @@ +"""Which plugins use which font, published for the web interface. + +The display service and the web interface are separate processes. Plugins +run, and register their fonts with ``FontManager.register_manager_font()``, +in the display service only; the web interface's Fonts tab lists the files in +``assets/fonts/`` from its own scan and has no FontManager to ask. So the +display service publishes a small snapshot to the shared cache directory -- +the same channel, and the same file permissions, as ``display_current_state`` +and ``plugin_error_snapshot``: files are 0660 and carry the cache directory's +group, so root writes and the web user reads. + + FONT_USAGE_KEY written by the display service only + +The snapshot:: + + {"generated_at": "2026-09-23T10:00:00", + "fonts": {"PressStart2P-Regular": ["calendar", "hello-world"], + "4x6-font": ["calendar"]}} + +``fonts`` is keyed the way ``GET /api/v3/fonts/catalog`` keys its rows: the +file name in ``assets/fonts/`` without its extension, as it is on disk. A +plugin names a font by FontManager family ("press_start", "5x7", "6x13b"), by +alias, or by path; :func:`catalog_key_for` resolves each through FontManager's +own catalog to the file it loads and keys it by that file's stem. Fonts that +live outside ``assets/fonts/`` (a plugin's own ``plugin_id::family`` fonts) +and families FontManager cannot resolve are left out: the Fonts tab has no +row for them. + +Only registrations are counted. ``get_font()`` does not know which plugin is +calling it, and many plugins open font files directly with PIL, so a font no +plugin registered may still be in use; the web interface says so. +""" + +import logging +import os +import threading +import time +from datetime import datetime +from typing import Any, Callable, Dict, FrozenSet, List, Optional, Tuple + +logger = logging.getLogger(__name__) + +FONT_USAGE_KEY = "font_usage_snapshot" + +#: How often the display service checks for a change. A check compares two +#: integers and a set of plugin ids; the snapshot is rebuilt and written only +#: when one of them moved, so plugins that register fonts every frame (the +#: countdown plugin registers per countdown at render time) cost nothing. +TICK_INTERVAL = 10.0 + +#: Rewrite an unchanged snapshot this often anyway. The cache's disk cleanup +#: deletes entries older than its default retention (30 days), and a display +#: service that runs longer than that without a font change would otherwise +#: turn the Fonts tab back to "unknown". +REFRESH_INTERVAL = 24 * 3600.0 + +FONT_EXTENSIONS = ('.ttf', '.otf', '.bdf') + +# Bounds on what the web interface accepts from the snapshot file. +_MAX_FONTS = 1000 +_MAX_PLUGINS_PER_FONT = 200 +_MAX_ID_CHARS = 200 + + +def _fonts_dir() -> str: + from src.common.font_layout import resolve_asset_path + return resolve_asset_path("assets/fonts") + + +def _same_dir(a: str, b: str) -> bool: + try: + return os.path.normcase(os.path.realpath(a)) == os.path.normcase(os.path.realpath(b)) + except (OSError, ValueError): + return False + + +def catalog_key_for(family: Any, font_catalog: Dict[str, str], + fonts_dir: Optional[str] = None) -> Optional[str]: + """The Fonts-tab catalog key for a family a plugin registered, or None. + + Resolution, in order: + + 1. ``family`` as a FontManager family -- a scanned file ("5x7", + "pressstart2p-regular"), an alias ("press_start", "four_by_six", + "five_by_seven", "tom_thumb"), or a plugin's ``plugin_id::family``. + FontManager keys scanned files in lower case, so the lookup falls back + to ``family.lower()``. + 2. ``family`` as a file name or path ("5x7.bdf", + "assets/fonts/4x6-font.ttf", an absolute path). + + The key is the resolved file's name without its extension, and only for a + file directly in ``assets/fonts/`` -- anything else has no catalog row. + """ + if not isinstance(family, str): + return None + name = family.strip() + if not name: + return None + fonts_dir = fonts_dir or _fonts_dir() + + path = font_catalog.get(name) or font_catalog.get(name.lower()) + if not path: + if not name.lower().endswith(FONT_EXTENSIONS): + return None + path = name + directory, filename = os.path.split(str(path)) + stem, ext = os.path.splitext(filename) + if not stem or ext.lower() not in FONT_EXTENSIONS: + return None + if directory: + if not os.path.isabs(directory): + from src.common.font_layout import resolve_asset_path + directory = resolve_asset_path(directory) + if not _same_dir(directory, fonts_dir): + return None + return stem + + +def build_font_usage(font_manager: Any, + plugin_ids: Optional[FrozenSet[str]] = None, + fonts_dir: Optional[str] = None) -> Dict[str, List[str]]: + """``{catalog key: sorted plugin ids}`` from FontManager's registrations. + + ``plugin_ids`` limits it to loaded plugins: a plugin whose constructor + registered fonts and which then failed validation is not using them. + """ + fonts_dir = fonts_dir or _fonts_dir() + # list() copies in one step under the GIL; register_manager_font() runs + # on the render thread while this runs on the publisher's. + registrations = list(getattr(font_manager, 'manager_fonts', {}).items()) + font_catalog = dict(getattr(font_manager, 'font_catalog', {})) + usage: Dict[str, set] = {} + for manager_id, elements in registrations: + if not isinstance(manager_id, str): + continue + if plugin_ids is not None and manager_id not in plugin_ids: + continue + for spec in list(elements.values()) if isinstance(elements, dict) else (): + family = spec.get('family') if isinstance(spec, dict) else None + key = catalog_key_for(family, font_catalog, fonts_dir) + if key is not None: + usage.setdefault(key, set()).add(manager_id) + return {key: sorted(ids) for key, ids in sorted(usage.items())} + + +class FontUsagePublisher: + """Publishes the display service's font usage to the shared cache. + + tick() is the whole job; start() calls it from a daemon thread every + TICK_INTERVAL seconds. Nothing here raises: a failure is logged at debug + and retried on the next tick. + """ + + def __init__(self, cache_manager: Any, font_manager: Any, plugin_manager: Any = None, + clock: Callable[[], float] = time.monotonic) -> None: + self.cache_manager = cache_manager + self.font_manager = font_manager + self.plugin_manager = plugin_manager + self._clock = clock + self._signature: Optional[Tuple[Any, ...]] = None + # None forces a first write, replacing whatever a previous run of the + # service left in the cache with this run's usage. + self._published: Optional[Dict[str, List[str]]] = None + self._published_at: Optional[float] = None + self._lock = threading.Lock() + self._stop = threading.Event() + self._thread: Optional[threading.Thread] = None + + def _loaded_plugin_ids(self) -> Optional[FrozenSet[str]]: + plugins = getattr(self.plugin_manager, 'plugins', None) + if not isinstance(plugins, dict): + return None + return frozenset(list(plugins)) + + def tick(self) -> bool: + """Publish if the usage changed. True if a snapshot was written.""" + with self._lock: + try: + plugin_ids = self._loaded_plugin_ids() + signature = ( + getattr(self.font_manager, 'manager_fonts_version', None), + getattr(self.font_manager, 'cache_generation', None), + plugin_ids, + ) + now = self._clock() + refresh_due = (self._published_at is not None + and now - self._published_at >= REFRESH_INTERVAL) + if signature == self._signature and not refresh_due: + return False + usage = build_font_usage(self.font_manager, plugin_ids) + if usage == self._published and not refresh_due: + self._signature = signature + return False + self.cache_manager.set(FONT_USAGE_KEY, { + 'generated_at': datetime.now().isoformat(timespec='seconds'), + 'fonts': usage, + }) + self._signature = signature + self._published = usage + self._published_at = now + return True + except Exception as err: # never let reporting break the display + logger.debug("Could not publish font usage: %s", err, exc_info=True) + return False + + def start(self, interval: float = TICK_INTERVAL) -> None: + """Tick now, then from a daemon thread until stop(). No-op while running.""" + if self._thread is not None and self._thread.is_alive(): + return + self._stop.clear() + + def run() -> None: + self.tick() + while not self._stop.wait(interval): + self.tick() + + self._thread = threading.Thread(target=run, name="font-usage-publisher", daemon=True) + self._thread.start() + + def stop(self) -> None: + self._stop.set() + if self._thread is not None: + self._thread.join(timeout=2) + self._thread = None + + +def start_font_usage_publisher(cache_manager: Any, font_manager: Any, + plugin_manager: Any) -> Optional[FontUsagePublisher]: + """Start publishing font usage for the web interface. Never raises. + + Call from the display service, once its plugins are loaded. + """ + try: + if cache_manager is None or font_manager is None or plugin_manager is None: + return None + publisher = FontUsagePublisher(cache_manager, font_manager, plugin_manager) + publisher.start() + return publisher + except Exception as err: + logger.warning("Font usage reporting to the web interface is unavailable: %s", err) + return None + + +# --- Reading side (web interface) ------------------------------------------- + +def read_font_usage(cache_manager: Any) -> Optional[Dict[str, Any]]: + """The display service's latest snapshot, or None if it has not published. + + memory_ttl=0: the display service writes this key, so only the file is + current. The contents are checked and bounded -- they arrive from another + process through a shared directory. + """ + try: + snapshot = cache_manager.get(FONT_USAGE_KEY, max_age=None, memory_ttl=0) + except Exception as err: + logger.debug("Could not read font usage: %s", err, exc_info=True) + return None + if not isinstance(snapshot, dict) or not isinstance(snapshot.get('fonts'), dict): + return None + fonts: Dict[str, List[str]] = {} + for key, ids in list(snapshot['fonts'].items())[:_MAX_FONTS]: + if not isinstance(key, str) or not isinstance(ids, list): + continue + clean = sorted({i[:_MAX_ID_CHARS] for i in ids[:_MAX_PLUGINS_PER_FONT] + if isinstance(i, str) and i}) + if clean: + fonts[key[:_MAX_ID_CHARS]] = clean + generated_at = snapshot.get('generated_at') + return { + 'generated_at': generated_at if isinstance(generated_at, str) else None, + 'fonts': fonts, + } diff --git a/src/plugin_system/plugin_manager.py b/src/plugin_system/plugin_manager.py index 98b1ee6c..27502f02 100644 --- a/src/plugin_system/plugin_manager.py +++ b/src/plugin_system/plugin_manager.py @@ -624,6 +624,13 @@ class PluginManager: # Delegate sub-module and cached-module cleanup to the loader self.plugin_loader.unregister_plugin_modules(plugin_id) + # Its font registrations go with it (the Fonts tab's "Used by"). + try: + if self.font_manager is not None and hasattr(self.font_manager, 'forget_manager_fonts'): + self.font_manager.forget_manager_fonts(plugin_id) + except Exception as e: + self.logger.debug("Could not forget fonts of %s: %s", plugin_id, e) + # Update state self.state_manager.set_state(plugin_id, PluginState.UNLOADED) self.state_manager.clear_state(plugin_id) diff --git a/test/test_font_usage.py b/test/test_font_usage.py new file mode 100644 index 00000000..8b4ddffa --- /dev/null +++ b/test/test_font_usage.py @@ -0,0 +1,484 @@ +"""The Fonts tab's "Used by" column, across the two services. + +Plugins register fonts with FontManager in the display service; the web +interface lists font files from its own scan and has no FontManager. The +display side publishes {catalog key: [plugin ids]} to the shared cache +(src/font_usage.py) and GET /api/v3/fonts/catalog merges it in per request. + +As in test_error_snapshot_cross_process.py, the two services are two +CacheManagers over one temporary directory. +""" +import os +import sys +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +import pytest + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from src.cache_manager import CacheManager # noqa: E402 +from src.font_manager import FontManager # noqa: E402 +from src import font_usage # noqa: E402 +from src.font_usage import ( # noqa: E402 + FONT_USAGE_KEY, REFRESH_INTERVAL, FontUsagePublisher, build_font_usage, + catalog_key_for, read_font_usage, start_font_usage_publisher, +) +from test._api_v3_test_helpers import api_v3_client, api_v3_module # noqa: F401,E402 + +REPO_FONTS = Path(__file__).resolve().parent.parent / "assets" / "fonts" + + +class FakeClock: + def __init__(self): + self.now = 1000.0 + + def __call__(self): + return self.now + + +@pytest.fixture(scope="module") +def fm(): + return FontManager({}) + + +@pytest.fixture +def shared_cache(tmp_path, monkeypatch): + """Two cache managers over one directory: the display's and the web's.""" + cache_dir = tmp_path / "cache" + cache_dir.mkdir() + monkeypatch.setattr(CacheManager, "_get_writable_cache_dir", lambda self: str(cache_dir)) + display_cache, web_cache = CacheManager(), CacheManager() + yield display_cache, web_cache + display_cache.stop_cleanup_thread() + web_cache.stop_cleanup_thread() + + +@pytest.fixture +def display(shared_cache): + """A FontManager, the loaded-plugin table and a publisher over them.""" + display_cache, _ = shared_cache + font_manager = FontManager({}) + plugin_manager = SimpleNamespace(plugins={}) + clock = FakeClock() + publisher = FontUsagePublisher(display_cache, font_manager, plugin_manager, clock=clock) + return font_manager, plugin_manager, publisher, clock + + +def _load(font_manager, plugin_manager, plugin_id, *families): + """What a plugin's constructor does, then the plugin manager.""" + for i, family in enumerate(families): + font_manager.register_manager_font(plugin_id, f"{plugin_id}.e{i}", family, 8) + plugin_manager.plugins[plugin_id] = object() + + +def _published(web_cache): + return web_cache.get(FONT_USAGE_KEY, max_age=None, memory_ttl=0) + + +# --- Normalisation to the catalog's keys ------------------------------------ + +class TestCatalogKeys: + """The web catalog keys a font by its file name without the extension, + as on disk. Plugins name fonts by FontManager family, alias or path.""" + + @pytest.mark.parametrize("family, key", [ + ("press_start", "PressStart2P-Regular"), + ("four_by_six", "4x6-font"), + ("five_by_seven", "5x7"), + ("tom_thumb", "tom-thumb"), + ]) + def test_aliases(self, fm, family, key): + assert catalog_key_for(family, fm.font_catalog) == key + + @pytest.mark.parametrize("family, key", [ + ("5x7", "5x7"), + ("4x6", "4x6"), # the BDF, not 4x6-font.ttf + ("6x13b", "6x13B"), # FontManager lower-cases stems + ("6x13B", "6x13B"), + ("PressStart2P-Regular", "PressStart2P-Regular"), + ("5by7.regular", "5by7.regular"), # a dot inside the stem + ]) + def test_scanned_families_keep_the_file_names_case(self, fm, family, key): + assert catalog_key_for(family, fm.font_catalog) == key + + @pytest.mark.parametrize("family, key", [ + ("assets/fonts/4x6-font.ttf", "4x6-font"), + ("5x7.bdf", "5x7"), + (str(REPO_FONTS / "MatrixChunky8.bdf"), "MatrixChunky8"), + ]) + def test_paths(self, fm, family, key): + assert catalog_key_for(family, fm.font_catalog) == key + + def test_absolute_path_through_the_catalog(self, fm, tmp_path): + catalog = {"custom": str(tmp_path / "Custom-Face.ttf")} + assert catalog_key_for("custom", catalog, fonts_dir=str(tmp_path)) == "Custom-Face" + + def test_a_font_outside_assets_fonts_has_no_row(self, fm, tmp_path): + # A plugin's own font ("plugin::family") lives in its directory; a + # file of the same name there is not the catalog's 5x7. + catalog = {"weather::5x7": str(tmp_path / "5x7.bdf")} + assert catalog_key_for("weather::5x7", catalog) is None + assert catalog_key_for(str(tmp_path / "5x7.bdf"), fm.font_catalog) is None + assert catalog_key_for("plugins/x/fonts/5x7.bdf", fm.font_catalog) is None + + @pytest.mark.parametrize("family", ["no_such_font", "", " ", None, 8, "notafont.txt"]) + def test_unresolvable(self, fm, family): + assert catalog_key_for(family, fm.font_catalog) is None + + def test_build_merges_aliases_and_names_of_one_file(self, fm): + font_manager = FontManager({}) + font_manager.register_manager_font("calendar", "calendar.title", "press_start", 8) + font_manager.register_manager_font("weather", "weather.t", "PressStart2P-Regular", 16) + font_manager.register_manager_font("weather", "weather.c", "four_by_six", 8) + font_manager.register_manager_font("weather", "weather.x", "no_such_font", 8) + assert build_font_usage(font_manager) == { + "4x6-font": ["weather"], + "PressStart2P-Regular": ["calendar", "weather"], + } + + +# --- Display side ----------------------------------------------------------- + +class TestPublishing: + def test_publishes_after_plugins_load(self, display, shared_cache): + font_manager, plugin_manager, publisher, _ = display + _, web_cache = shared_cache + _load(font_manager, plugin_manager, "calendar", "four_by_six", "press_start") + _load(font_manager, plugin_manager, "of-the-day", "press_start") + assert publisher.tick() is True + snapshot = _published(web_cache) + assert snapshot["fonts"] == { + "4x6-font": ["calendar"], + "PressStart2P-Regular": ["calendar", "of-the-day"], + } + assert snapshot["generated_at"] + + def test_a_plugin_that_failed_to_load_is_not_listed(self, display, shared_cache): + # Its constructor registered fonts, then validation failed, so it + # never reached plugin_manager.plugins. + font_manager, plugin_manager, publisher, _ = display + _, web_cache = shared_cache + font_manager.register_manager_font("broken", "broken.x", "press_start", 8) + _load(font_manager, plugin_manager, "calendar", "four_by_six") + publisher.tick() + assert _published(web_cache)["fonts"] == {"4x6-font": ["calendar"]} + + def test_load_and_unload_later_republish(self, display, shared_cache): + font_manager, plugin_manager, publisher, _ = display + _, web_cache = shared_cache + _load(font_manager, plugin_manager, "calendar", "four_by_six") + publisher.tick() + + _load(font_manager, plugin_manager, "weather", "four_by_six", "five_by_seven") + assert publisher.tick() is True + assert _published(web_cache)["fonts"] == { + "4x6-font": ["calendar", "weather"], "5x7": ["weather"]} + + # What PluginManager.unload_plugin does. + del plugin_manager.plugins["weather"] + font_manager.forget_manager_fonts("weather") + assert publisher.tick() is True + assert _published(web_cache)["fonts"] == {"4x6-font": ["calendar"]} + + def test_first_tick_replaces_a_previous_runs_snapshot(self, display, shared_cache): + _, web_cache = shared_cache + web_cache.set(FONT_USAGE_KEY, {"generated_at": "old", "fonts": {"5x7": ["gone"]}}) + _, _, publisher, _ = display + assert publisher.tick() is True + assert _published(web_cache)["fonts"] == {} + + def test_nothing_is_written_when_nothing_changed(self, display): + font_manager, plugin_manager, publisher, clock = display + _load(font_manager, plugin_manager, "countdown", "press_start") + publisher.tick() + publisher.cache_manager = MagicMock(wraps=publisher.cache_manager) + with patch.object(font_usage, "build_font_usage", + wraps=font_usage.build_font_usage) as build: + for _ in range(20): + # The countdown plugin registers per countdown at render time. + font_manager.register_manager_font("countdown", "countdown.e0", "press_start", 8) + clock.now += 5 + assert publisher.tick() is False + build.assert_not_called() + publisher.cache_manager.set.assert_not_called() + + def test_a_registration_that_leaves_usage_unchanged_is_not_written(self, display): + font_manager, plugin_manager, publisher, _ = display + _load(font_manager, plugin_manager, "countdown", "press_start") + publisher.tick() + publisher.cache_manager = MagicMock(wraps=publisher.cache_manager) + # A new element, same font: the version moves, the usage does not. + font_manager.register_manager_font("countdown", "countdown.c2.value", "press_start", 12) + assert publisher.tick() is False + publisher.cache_manager.set.assert_not_called() + + def test_an_unchanged_snapshot_is_refreshed_daily(self, display): + # The cache's disk cleanup deletes entries older than 30 days. + _, _, publisher, clock = display + publisher.tick() + publisher.cache_manager = MagicMock(wraps=publisher.cache_manager) + clock.now += REFRESH_INTERVAL - 1 + assert publisher.tick() is False + clock.now += 1 + assert publisher.tick() is True + publisher.cache_manager.set.assert_called_once() + + +class TestFailureIsolation: + def test_a_failing_cache_write_does_not_raise_and_is_retried(self, display, shared_cache): + font_manager, plugin_manager, publisher, _ = display + _, web_cache = shared_cache + _load(font_manager, plugin_manager, "calendar", "four_by_six") + real = publisher.cache_manager + publisher.cache_manager = MagicMock() + publisher.cache_manager.set.side_effect = PermissionError("read-only cache") + assert publisher.tick() is False + publisher.cache_manager = real + assert publisher.tick() is True + assert _published(web_cache)["fonts"] == {"4x6-font": ["calendar"]} + + def test_a_broken_font_manager_does_not_raise(self, shared_cache): + display_cache, _ = shared_cache + broken = SimpleNamespace(manager_fonts=None, font_catalog={}) + publisher = FontUsagePublisher(display_cache, broken, SimpleNamespace(plugins={})) + assert publisher.tick() is False + + def test_start_never_raises(self): + assert start_font_usage_publisher(MagicMock(), MagicMock(), None) is None + with patch.object(font_usage.FontUsagePublisher, "start", + side_effect=RuntimeError("no threads")): + assert start_font_usage_publisher(MagicMock(), MagicMock(), MagicMock()) is None + + def test_start_publishes_from_a_thread(self, shared_cache, monkeypatch): + display_cache, web_cache = shared_cache + font_manager = FontManager({}) + plugin_manager = SimpleNamespace(plugins={}) + _load(font_manager, plugin_manager, "calendar", "press_start") + publisher = start_font_usage_publisher(display_cache, font_manager, plugin_manager) + try: + import time + deadline = time.monotonic() + 5 + while _published(web_cache) is None and time.monotonic() < deadline: + time.sleep(0.02) + assert _published(web_cache)["fonts"] == {"PressStart2P-Regular": ["calendar"]} + finally: + publisher.stop() + + +class TestFontManagerRegistrations: + def test_version_moves_only_when_a_family_changes(self): + font_manager = FontManager({}) + v0 = font_manager.manager_fonts_version + font_manager.register_manager_font("p", "p.a", "press_start", 8) + v1 = font_manager.manager_fonts_version + font_manager.register_manager_font("p", "p.a", "press_start", 10) # size only + assert font_manager.manager_fonts_version == v1 > v0 + font_manager.register_manager_font("p", "p.a", "four_by_six", 8) + assert font_manager.manager_fonts_version > v1 + + def test_forget_drops_every_registration_of_that_manager(self): + font_manager = FontManager({}) + font_manager.register_manager_font("p", "p.a", "press_start", 8) + font_manager.register_manager_font("q", "q.a", "press_start", 8) + version = font_manager.manager_fonts_version + font_manager.forget_manager_fonts("p") + assert "p" not in font_manager.manager_fonts + assert "p.a" not in font_manager.detected_fonts + assert "q" in font_manager.manager_fonts and "q.a" in font_manager.detected_fonts + assert font_manager.manager_fonts_version > version + font_manager.forget_manager_fonts("never-registered") # no error + + def test_unload_plugin_forgets_its_fonts(self, tmp_path): + from src.plugin_system.plugin_manager import PluginManager + font_manager = FontManager({}) + pm = PluginManager(plugins_dir=str(tmp_path), config_manager=None, + display_manager=None, cache_manager=None, + font_manager=font_manager) + try: + font_manager.register_manager_font("calendar", "calendar.t", "press_start", 8) + pm.plugins["calendar"] = SimpleNamespace() + assert pm.unload_plugin("calendar") is True + assert "calendar" not in font_manager.manager_fonts + finally: + pm.stop_update_worker() + + def test_unload_survives_a_font_manager_that_raises(self, tmp_path): + from src.plugin_system.plugin_manager import PluginManager + font_manager = MagicMock() + font_manager.forget_manager_fonts.side_effect = RuntimeError("boom") + pm = PluginManager(plugins_dir=str(tmp_path), config_manager=None, + display_manager=None, cache_manager=None, + font_manager=font_manager) + try: + pm.plugins["calendar"] = SimpleNamespace() + assert pm.unload_plugin("calendar") is True + finally: + pm.stop_update_worker() + + +def test_display_controller_starts_the_publisher(test_display_controller, mock_cache_manager): + publisher = test_display_controller._font_usage_publisher + assert isinstance(publisher, FontUsagePublisher) + assert publisher.plugin_manager is test_display_controller.plugin_manager + publisher.tick() + assert "fonts" in mock_cache_manager._memory_cache[FONT_USAGE_KEY] + + +# --- Reading side ----------------------------------------------------------- + +class TestReading: + def test_nothing_published(self, shared_cache): + _, web_cache = shared_cache + assert read_font_usage(web_cache) is None + + @pytest.mark.parametrize("junk", [ + "a string", {"fonts": "nope"}, {"generated_at": "x"}, [1, 2], + ]) + def test_a_malformed_snapshot_reads_as_unknown(self, shared_cache, junk): + _, web_cache = shared_cache + web_cache.set(FONT_USAGE_KEY, junk) + assert read_font_usage(web_cache) is None + + def test_bad_entries_are_dropped(self, shared_cache): + _, web_cache = shared_cache + web_cache.set(FONT_USAGE_KEY, {"generated_at": 5, "fonts": { + "5x7": ["b", "a", 3, None, "", "a"], "4x6": "calendar", "tom-thumb": [], + }}) + assert read_font_usage(web_cache) == {"generated_at": None, "fonts": {"5x7": ["a", "b"]}} + + def test_a_cache_that_raises_reads_as_unknown(self): + cache = MagicMock() + cache.get.side_effect = OSError("gone") + assert read_font_usage(cache) is None + + +# --- Web side --------------------------------------------------------------- + +@pytest.fixture +def web(api_v3_module, api_v3_client, shared_cache, tmp_path): # noqa: F811 + """The real blueprint over a scratch assets/fonts and the shared cache.""" + _, web_cache = shared_cache + api_v3_module.api_v3.cache_manager = web_cache + fonts_dir = tmp_path / "root" / "assets" / "fonts" + fonts_dir.mkdir(parents=True) + for name in ("PressStart2P-Regular.ttf", "5x7.bdf", "my-custom.ttf"): + (fonts_dir / name).write_bytes(b"not really a font") + from web_interface.cache import delete_cached + delete_cached("fonts_catalog") + with patch("web_interface.blueprints.api_v3.fonts.PROJECT_ROOT", tmp_path / "root"): + yield api_v3_client, web_cache, fonts_dir + delete_cached("fonts_catalog") + + +def _catalog(client): + response = client.get("/api/v3/fonts/catalog") + assert response.status_code == 200, response.get_data(as_text=True) + return response.get_json()["data"] + + +class TestCatalogEndpoint: + def test_unknown_until_the_display_service_reports(self, web): + client, _, _ = web + data = _catalog(client) + assert data["font_usage"] == {"available": False, "generated_at": None} + assert {key: info["used_by"] for key, info in data["catalog"].items()} == { + "PressStart2P-Regular": None, "5x7": None, "my-custom": None} + + def test_usage_is_merged_into_the_rows(self, web): + client, web_cache, _ = web + web_cache.set(FONT_USAGE_KEY, {"generated_at": "2026-09-23T10:00:00", "fonts": { + "PressStart2P-Regular": ["calendar", "clock-simple"], + "my-custom": ["football-scoreboard"], + "not-in-the-catalog": ["weather"], + }}) + data = _catalog(client) + assert data["font_usage"] == {"available": True, "generated_at": "2026-09-23T10:00:00"} + rows = data["catalog"] + assert rows["PressStart2P-Regular"]["used_by"] == ["calendar", "clock-simple"] + assert rows["my-custom"]["used_by"] == ["football-scoreboard"] + assert rows["5x7"]["used_by"] == [] + assert "not-in-the-catalog" not in rows + # The existing fields are still there. + assert rows["5x7"]["type"] == "bdf" and rows["5x7"]["is_system"] is True + + def test_keys_match_case_insensitively(self, web): + client, web_cache, _ = web + web_cache.set(FONT_USAGE_KEY, {"fonts": {"pressstart2p-regular": ["calendar"], + "PressStart2P-Regular": ["weather"]}}) + rows = _catalog(client)["catalog"] + assert rows["PressStart2P-Regular"]["used_by"] == ["calendar", "weather"] + + def test_the_scan_stays_cached_while_usage_is_read_per_request(self, web): + client, web_cache, _ = web + _catalog(client) # fills the 5-minute cache + from web_interface.blueprints.api_v3 import fonts as fonts_module + with patch.object(fonts_module.os, "listdir", + side_effect=AssertionError("rescanned assets/fonts")): + web_cache.set(FONT_USAGE_KEY, {"fonts": {"5x7": ["weather"]}}) + assert _catalog(client)["catalog"]["5x7"]["used_by"] == ["weather"] + web_cache.set(FONT_USAGE_KEY, {"fonts": {"5x7": ["calendar"]}}) + assert _catalog(client)["catalog"]["5x7"]["used_by"] == ["calendar"] + # ...and usage never leaks into the cached scan. + from web_interface.cache import get_cached + cached = get_cached("fonts_catalog", ttl_seconds=300) + assert cached and all("used_by" not in info for info in cached.values()) + + def test_end_to_end_from_the_display_publisher(self, web, display): + client, _, fonts_dir = web + font_manager, plugin_manager, publisher, _ = display + _load(font_manager, plugin_manager, "calendar", "press_start", "five_by_seven") + publisher.tick() + rows = _catalog(client)["catalog"] + assert rows["PressStart2P-Regular"]["used_by"] == ["calendar"] + assert rows["5x7"]["used_by"] == ["calendar"] + # A later change reaches the web process too: it reads the file, not + # the copy its own memory tier kept from the first read. + _load(font_manager, plugin_manager, "weather", "five_by_seven") + publisher.tick() + assert _catalog(client)["catalog"]["5x7"]["used_by"] == ["calendar", "weather"] + + +class TestDeleteInUseFont: + """The UI warns with the catalog's used_by; the server does not block.""" + + def test_the_catalog_names_the_plugins_and_delete_still_works(self, web): + client, web_cache, fonts_dir = web + web_cache.set(FONT_USAGE_KEY, {"fonts": {"my-custom": ["football-scoreboard", "clock-simple"]}}) + assert _catalog(client)["catalog"]["my-custom"]["used_by"] == [ + "clock-simple", "football-scoreboard"] + response = client.delete("/api/v3/fonts/my-custom") + assert response.status_code == 200, response.get_json() + assert not (fonts_dir / "my-custom.ttf").exists() + assert "my-custom" not in _catalog(client)["catalog"] + + def test_system_fonts_are_still_refused(self, web): + client, web_cache, fonts_dir = web + web_cache.set(FONT_USAGE_KEY, {"fonts": {}}) + assert client.delete("/api/v3/fonts/5x7").status_code == 403 + assert (fonts_dir / "5x7.bdf").exists() + + +class TestFontsTemplate: + """fonts.html renders usage with DOM text APIs and warns before delete.""" + + TEMPLATE = (Path(__file__).resolve().parent.parent / "web_interface" / "templates" + / "v3" / "partials" / "fonts.html").read_text(encoding="utf-8") + + def test_used_by_is_never_written_as_html(self): + assert "usedSpan.textContent = font.usedBy.join(', ')" in self.TEMPLATE + assert "usedSpan.innerHTML" not in self.TEMPLATE + + def test_delete_confirms_with_the_plugins(self): + assert "Used by: ${usedBy.join(', ')}" in self.TEMPLATE + assert "confirm(deleteFontConfirmMessage(fontFamily, usedBy))" in self.TEMPLATE + + def test_catalog_fetches_bypass_the_browser_cache(self): + # /api/v3 GETs carry max-age=5. The usage check just before a delete + # would otherwise be the copy the post-delete reload is served, and + # the deleted font would stay listed. + fetches = [line for line in self.TEMPLATE.splitlines() + if "fetch(" in line and "/api/v3/fonts/catalog" in line] + assert len(fetches) == 2 + assert all("cache: 'no-store'" in line for line in fetches) diff --git a/web_interface/blueprints/api_v3/fonts.py b/web_interface/blueprints/api_v3/fonts.py index db0d7d5a..2da6d753 100644 --- a/web_interface/blueprints/api_v3/fonts.py +++ b/web_interface/blueprints/api_v3/fonts.py @@ -9,6 +9,46 @@ from web_interface.blueprints.api_v3 import ( ) +def _catalog_response(catalog): + """The catalog response, with each font's ``used_by`` merged in now. + + Usage comes from the display service (src/font_usage.py) and changes + independently of the files, so it is read per request and never stored + in the 5-minute ``fonts_catalog`` cache: entries are copied, not edited. + ``used_by`` is a list of plugin ids, empty when no loaded plugin + registered the font, and None when the display service has not reported. + """ + from src.font_usage import read_font_usage + from web_interface.blueprints.api_v3.display import _cache_manager + try: + usage = read_font_usage(_cache_manager()) + except Exception: + logger.debug("[FontCatalog] Could not read font usage", exc_info=True) + usage = None + + used_by = {} + if usage is not None: + # The snapshot keys fonts by file stem, as the catalog does; match + # case-insensitively too, since FontManager lower-cases families. + by_lower = {key.lower(): key for key in catalog} + for key, plugin_ids in usage['fonts'].items(): + row = key if key in catalog else by_lower.get(key.lower()) + if row is not None: + used_by.setdefault(row, set()).update(plugin_ids) + + merged = { + key: dict(info, used_by=(sorted(used_by.get(key, ())) if usage is not None else None)) + for key, info in catalog.items() + } + return jsonify({'status': 'success', 'data': { + 'catalog': merged, + 'font_usage': { + 'available': usage is not None, + 'generated_at': usage['generated_at'] if usage is not None else None, + }, + }}) + + @api_v3.route('/fonts/catalog', methods=['GET']) def get_fonts_catalog(): """Get fonts catalog""" @@ -18,7 +58,7 @@ def get_fonts_catalog(): from web_interface.cache import get_cached, set_cached cached_result = get_cached('fonts_catalog', ttl_seconds=300) if cached_result is not None: - return jsonify({'status': 'success', 'data': {'catalog': cached_result}}) + return _catalog_response(cached_result) except ImportError: # Cache not available, continue without caching get_cached = None @@ -116,7 +156,7 @@ def get_fonts_catalog(): except Exception: logger.error("[FontCatalog] Failed to cache fonts_catalog", exc_info=True) - return jsonify({'status': 'success', 'data': {'catalog': catalog}}) + return _catalog_response(catalog) except Exception as e: logger.error("%s failed", request.path, exc_info=True) return jsonify({'status': 'error', diff --git a/web_interface/templates/v3/partials/fonts.html b/web_interface/templates/v3/partials/fonts.html index 3994bd5d..703f3165 100644 --- a/web_interface/templates/v3/partials/fonts.html +++ b/web_interface/templates/v3/partials/fonts.html @@ -9,10 +9,11 @@

Available Font Families

-
+
Loading...
-

All available font families in the system

+

All available font families in the system. +

@@ -299,8 +300,11 @@ async function loadFontData() { try { // Use absolute URLs to ensure they work when loaded via HTMX + // no-store: /api/v3 GETs are browser-cacheable for 5 s, and this + // reload follows an upload or delete (or the usage check before one), + // so a cached copy would show the list as it was before the change. const [catalogRes, tokensRes] = await Promise.all([ - fetch(`/api/v3/fonts/catalog`), + fetch(`/api/v3/fonts/catalog`, { cache: 'no-store' }), fetch(`/api/v3/fonts/tokens`) ]); @@ -325,6 +329,7 @@ async function loadFontData() { // Update both window properties and local references window.fontCatalog = catalogData.data.catalog || {}; + window.fontUsage = catalogData.data.font_usage || { available: false, generated_at: null }; window.fontTokens = tokensData.data.tokens || {}; // Update local variable references @@ -355,10 +360,29 @@ async function loadFontData() { } } +// The plugin ids that use a font, as the catalog reports them: an array +// (possibly empty) when the display service has reported, null when not. +function fontUsedBy(fontInfo) { + if (!fontInfo || typeof fontInfo !== 'object') return null; + return Array.isArray(fontInfo.used_by) + ? fontInfo.used_by.filter(id => typeof id === 'string') + : null; +} + function updateAvailableFontsDisplay() { const container = document.getElementById('available-fonts'); if (!container) return; + const usage = window.fontUsage || {}; + const note = document.getElementById('font-usage-note'); + if (note) { + const reported = usage.generated_at + ? ` (reported ${String(usage.generated_at).replace('T', ' ')})` : ''; + note.textContent = usage.available + ? `"Used by" lists the running plugins that registered the font with the display service${reported}. A plugin that opens a font file directly is not listed.` + : '"Used by" is unknown: the display service has not reported which fonts its plugins use. Start it, or reload this tab a few seconds after it starts.'; + } + if (Object.keys(fontCatalog).length === 0) { container.innerHTML = '
No fonts available
'; return; @@ -371,18 +395,34 @@ function updateAvailableFontsDisplay() { const fontType = typeof fontInfo === 'object' ? (fontInfo.type || '').toUpperCase() : ''; // Use is_system flag from API (single source of truth) const isSystem = typeof fontInfo === 'object' ? (fontInfo.is_system === true) : false; - return { name, filename, displayName, fontType, fontPath, isSystem }; + const usedBy = fontUsedBy(fontInfo); + return { name, filename, displayName, fontType, fontPath, isSystem, usedBy }; }).sort((a, b) => a.displayName.localeCompare(b.displayName)); - // Build list using DOM APIs to prevent XSS + // Build list using DOM APIs to prevent XSS (plugin ids are untrusted too) container.innerHTML = ''; + + const header = document.createElement('div'); + header.className = 'flex items-center gap-2 pb-1 mb-1 border-b border-gray-600 text-xs uppercase text-gray-400'; + const fontHead = document.createElement('span'); + fontHead.className = 'flex-1 min-w-0'; + fontHead.textContent = 'Font'; + const usedHead = document.createElement('span'); + usedHead.className = 'flex-1 min-w-0'; + usedHead.textContent = 'Used by'; + const actionHead = document.createElement('span'); + actionHead.className = 'w-24 flex-shrink-0'; + header.append(fontHead, usedHead, actionHead); + container.appendChild(header); + fontEntries.forEach(font => { const row = document.createElement('div'); - row.className = 'flex items-center justify-between py-1 border-b border-gray-700 last:border-0'; + row.className = 'flex items-start gap-2 py-1 border-b border-gray-700 last:border-0'; const nameSpan = document.createElement('span'); - nameSpan.className = 'truncate flex-1'; + nameSpan.className = 'truncate flex-1 min-w-0'; nameSpan.textContent = font.displayName; + nameSpan.title = font.filename; if (font.fontType) { const typeSpan = document.createElement('span'); @@ -393,29 +433,83 @@ function updateAvailableFontsDisplay() { row.appendChild(nameSpan); + // Wraps rather than truncates: the list is what a delete is weighed + // against, and a title tooltip is out of reach on a phone. + const usedSpan = document.createElement('span'); + usedSpan.className = 'flex-1 min-w-0 break-words'; + if (font.usedBy === null) { + usedSpan.classList.add('text-gray-500', 'italic'); + usedSpan.textContent = 'unknown'; + usedSpan.title = 'The display service has not reported which fonts its plugins use'; + } else if (font.usedBy.length === 0) { + usedSpan.classList.add('text-gray-500'); + usedSpan.textContent = '—'; + usedSpan.title = 'No running plugin registered this font'; + } else { + usedSpan.classList.add('text-gray-300'); + usedSpan.textContent = font.usedBy.join(', '); + } + row.appendChild(usedSpan); + + const actionCell = document.createElement('span'); + actionCell.className = 'w-24 flex-shrink-0 text-right'; if (font.isSystem) { const systemBadge = document.createElement('span'); - systemBadge.className = 'text-gray-600 text-xs ml-2'; + systemBadge.className = 'text-gray-600 text-xs'; systemBadge.textContent = '[system]'; - row.appendChild(systemBadge); + actionCell.appendChild(systemBadge); } else { const deleteBtn = document.createElement('button'); - deleteBtn.className = 'text-red-400 hover:text-red-300 text-xs ml-2'; + deleteBtn.className = 'text-red-400 hover:text-red-300 text-xs'; deleteBtn.title = 'Delete font'; deleteBtn.textContent = '[delete]'; deleteBtn.dataset.fontName = font.name; deleteBtn.addEventListener('click', function() { deleteFont(this.dataset.fontName); }); - row.appendChild(deleteBtn); + actionCell.appendChild(deleteBtn); } + row.appendChild(actionCell); container.appendChild(row); }); } +// Which plugins use a font now. Re-reads the catalog so the warning reflects +// the display service's latest report rather than the one the tab opened +// with; falls back to that one if the request fails. +async function currentFontUsedBy(fontName) { + const own = (obj) => obj && typeof obj === 'object' + && Object.prototype.hasOwnProperty.call(obj, fontName); + try { + const response = await fetch('/api/v3/fonts/catalog', { cache: 'no-store' }); + if (response.ok) { + const catalog = (await response.json())?.data?.catalog; + if (own(catalog)) return fontUsedBy(catalog[fontName]); + } + } catch (error) { + console.warn('Could not refresh font usage before delete:', error); + } + return own(fontCatalog) ? fontUsedBy(fontCatalog[fontName]) : null; +} + +// The confirm() text for deleting a font. Plain text: confirm() renders no +// HTML, so a plugin id cannot inject markup here. +function deleteFontConfirmMessage(fontFamily, usedBy) { + if (Array.isArray(usedBy) && usedBy.length > 0) { + return `Font "${fontFamily}" is in use.\n\nUsed by: ${usedBy.join(', ')}.\n\n` + + 'Delete anyway? This action cannot be undone.'; + } + const base = `Are you sure you want to delete the font "${fontFamily}"? This action cannot be undone.`; + if (usedBy === null) { + return `${base}\n\nWhich plugins use it is unknown: the display service has not reported.`; + } + return base; +} + async function deleteFont(fontFamily) { - if (!confirm(`Are you sure you want to delete the font "${fontFamily}"? This action cannot be undone.`)) { + const usedBy = await currentFontUsedBy(fontFamily); + if (!confirm(deleteFontConfirmMessage(fontFamily, usedBy))) { return; }