feat(web): show which plugins use each font; warn before deleting one (#619)

* feat(web): show which plugins use each font, warn before deleting one

The Fonts tab lists font files from the web process's own scan, and the
plugins that register fonts run in the display process, so the tab had no
way to say whether a font was in use before deleting it.

The display service now publishes {catalog key: [plugin ids]} to the
shared cache (font_usage_snapshot, src/font_usage.py), built from the
loaded plugins' FontManager.register_manager_font() registrations. A
daemon thread checks every 10 s and writes only when the usage changed
(plus a daily refresh so cache cleanup cannot expire it); it never raises.
Families, aliases (press_start, four_by_six, ...) and paths are resolved
through FontManager's catalog to the file stem the Fonts tab keys rows by;
fonts outside assets/fonts are left out. Unloading a plugin drops its
registrations (new FontManager.forget_manager_fonts).

GET /api/v3/fonts/catalog merges used_by into each row per request (the
5-minute scan cache is copied, never edited): a list of plugin ids, or
null when the display service has not reported. The tab shows a Used by
column ("unknown" / "-" / ids, rendered as text) and deleting an in-use
font names the plugins in the confirmation, from a fresh read. The server
still refuses only system fonts. Catalog fetches bypass the browser's
5-second API cache, which otherwise served the pre-delete list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: call forget_manager_fonts through a hasattr check pylint can follow

getattr(..., None) then callable() is fine at runtime, but pylint's E1102
("not callable") can't see through it, and Codacy fails the check on it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Chuck
2026-09-23 16:50:42 -04:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 9a1f94f793
commit f3894916a9
10 changed files with 995 additions and 22 deletions
+7
View File
@@ -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:
+30 -2
View File
@@ -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
+21 -5
View File
@@ -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/<font_family><ext>`.
**DELETE** `/api/v3/fonts/<font_family>`
Delete an uploaded font (`<font_family>` 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
+7
View File
@@ -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()
+19 -1
View File
@@ -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]:
"""
+272
View File
@@ -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,
}
+7
View File
@@ -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)
+484
View File
@@ -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)
+42 -2
View File
@@ -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',
+106 -12
View File
@@ -9,10 +9,11 @@
<!-- Available Fonts -->
<div class="bg-gray-50 rounded-lg p-4">
<h3 class="text-md font-medium text-gray-900 mb-3">Available Font Families</h3>
<div id="available-fonts" class="bg-gray-800 text-gray-100 font-mono text-sm p-3 rounded h-40 overflow-y-auto">
<div id="available-fonts" class="bg-gray-800 text-gray-100 font-mono text-sm p-3 rounded h-64 overflow-y-auto">
<div class="text-gray-400">Loading...</div>
</div>
<p class="text-sm text-gray-600 mt-2">All available font families in the system</p>
<p class="text-sm text-gray-600 mt-2">All available font families in the system.
<span id="font-usage-note"></span></p>
</div>
</div>
@@ -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 = '<div class="text-gray-400">No fonts available</div>';
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;
}