mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-14 02:56:36 +00:00
Compare commits
18
Commits
v3.8.0
...
f9e9eeba00
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f9e9eeba00 | ||
|
|
a5342939af | ||
|
|
59144b0088 | ||
|
|
d28f562eb9 | ||
|
|
b950871ca8 | ||
|
|
a46a908c79 | ||
|
|
e86dc9c27b | ||
|
|
6cd886cd78 | ||
|
|
c5858f1e5e | ||
|
|
521f81272f | ||
|
|
90d6d91c27 | ||
|
|
f91f1b02e3 | ||
|
|
75026115d5 | ||
|
|
9998aaf51c | ||
|
|
2cfbc1e969 | ||
|
|
a694b23cd1 | ||
|
|
7216d254a9 | ||
|
|
0d7e409d77 |
@@ -17,6 +17,82 @@ release that ships it.
|
||||
accepts both, but the store flags the old spelling as deprecated
|
||||
(`store_manager.py`) and only the new one is in `schema/manifest_schema.json`.
|
||||
|
||||
## Unreleased
|
||||
|
||||
**Per-element display customization, and the last mile of it into the web UI.**
|
||||
A user can set the font, size, colour, position, visibility and alignment of
|
||||
individual display elements per plugin -- and, where a plugin has display
|
||||
modes, separately per mode.
|
||||
|
||||
New public API a plugin may import via `src.*` (floor on the release that
|
||||
ships this):
|
||||
|
||||
- `src.element_style.layout_offset(config, element, axis, default, mode)` and
|
||||
`element_color(config, element, default, mode)` — the stateless reads the
|
||||
scoreboard helpers share. There were three copies of the offset read and two
|
||||
of the colour read; these are the one implementation, and they carry the
|
||||
element-name aliasing and the per-mode lookup.
|
||||
- `src.element_style.alias_keys(element)` — the names one element may be stored
|
||||
under. The style block names elements `score_text` while the layout block
|
||||
says `score`, and `records`/`record` and `status_text`/`status` split seven
|
||||
to two across the published schemas. A lookup tries the exact name first, so
|
||||
this is inert for a config that already matches.
|
||||
- `src.element_style.native_bdf_size(font)` — the one pixel size a bitmap font
|
||||
can render at, or None for a scalable one. The web UI needs this to know
|
||||
whether a size control can take effect at all.
|
||||
- `ElementStyleResolver(config, defaults, mode=...)` plus `visible`, `align`
|
||||
and `scale` on `ElementStyle`. The mode binds to the resolver rather than
|
||||
being passed per call, so a plugin with one instance per mode makes every
|
||||
existing lookup mode-aware by setting one class attribute.
|
||||
- `BasePlugin.styles` / `styles_for(mode)` / `STYLE_MODE` — the accessor every
|
||||
plugin inherits, so adopting this is no longer a guarded import plus schema
|
||||
discovery plus resolver invalidation in each plugin.
|
||||
- `SportsCoreSharedMixin._get_layout_offset` — promoted from the plugins'
|
||||
bundled copies. Each still carries its own, which wins by MRO, so adopting
|
||||
it is a deletion.
|
||||
|
||||
Schema and web UI:
|
||||
|
||||
- A `customization` block is now rendered by a composite style editor: one row
|
||||
per element rather than nested accordions, with a tab per declared mode.
|
||||
Plugins that hand-wrote their style blocks get it without a plugin release;
|
||||
`x-style-elements` and `x-style-modes` declare it compactly.
|
||||
- Font fields become a real picker rather than a hardcoded `enum`, so a font
|
||||
the user uploads is selectable. Bitmap fonts taller than the element's
|
||||
declared size ceiling are filtered out, because a bitmap font ignores
|
||||
`font_size` and renders at its own size.
|
||||
- `/static/plugin-widgets/<plugin>/<widget>.js` serves a plugin's own web-UI
|
||||
widgets. The client half and the docs already existed; nothing served them.
|
||||
|
||||
Fixed:
|
||||
|
||||
- A bitmap font asked for a size it has no strike for fell back to
|
||||
*PressStart2P* — a different typeface — rather than to its own native size.
|
||||
32 of the 35 shipped fonts are bitmap, so this was reachable for most font
|
||||
choices.
|
||||
- The plugin config form read `config_schema.json` directly while the save
|
||||
route read it through `SchemaManager`. Only the latter expands a compact
|
||||
`x-style-elements` declaration, so a plugin using that form had a
|
||||
customization section that rendered as empty space.
|
||||
- `unshare_element_fonts` rebuilt faces through bare `ImageFont.truetype`,
|
||||
bypassing the layout engine `src/common/font_layout.py` pins. These were the
|
||||
only two call sites in `src/` doing so.
|
||||
- The form parser compared a schema type to a bare string, so a nullable field
|
||||
(`["array", "null"]`) never had its indexed colour inputs recombined, and a
|
||||
blank one became `[]` rather than null.
|
||||
|
||||
Removed:
|
||||
|
||||
- The Fonts tab's "Element Font Overrides" panel and its three endpoints. They
|
||||
reported success and saved nothing, and the element keys the panel offered
|
||||
(`nfl.live.score`, `clock.time`) are read by no plugin, so wiring them to the
|
||||
real `FontManager` methods would still have changed nothing on the panel.
|
||||
Per-element font choice now lives in each plugin's own config editor.
|
||||
- "Detected Manager Fonts", which listed every installed font with a hardcoded
|
||||
usage count.
|
||||
- Two dead client-side config-form renderers in `app-shell.js` (~580 lines) and
|
||||
the legacy `plugins/config_manager.js`, superseded by server-side rendering.
|
||||
|
||||
## 3.3.0
|
||||
|
||||
**The release the sports scoreboards floor on to delete their bundled copies.**
|
||||
|
||||
+51
-11
@@ -285,7 +285,9 @@ Guidelines:
|
||||
|
||||
### Step 1: Create Widget File
|
||||
|
||||
Create a JavaScript file in your plugin directory. The recommended location is `widgets/[widget-name].js`:
|
||||
Create a JavaScript file in your plugin's `widgets/` directory, named
|
||||
`widgets/[widget-name].js`. The directory is not optional: it is the only
|
||||
place the core will serve a widget from.
|
||||
|
||||
```javascript
|
||||
// Ensure LEDMatrixWidgets registry is available
|
||||
@@ -366,7 +368,29 @@ window.LEDMatrixWidgets.register('my-custom-widget', {
|
||||
});
|
||||
```
|
||||
|
||||
### Step 2: Reference Widget in Schema
|
||||
### Step 2: Declare the Widget in `manifest.json`
|
||||
|
||||
The manifest is the allowlist. A widget is served only if the plugin declares
|
||||
it, so shipping a file under `widgets/` does not by itself publish it:
|
||||
|
||||
```json
|
||||
{
|
||||
"widgets": [
|
||||
{
|
||||
"name": "my-custom-widget",
|
||||
"script": "my-custom-widget.js",
|
||||
"description": "What this widget is for"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`name` is what you use in `x-widget` and in the URL. `script` is optional and
|
||||
defaults to `[name].js`; it must be a plain filename directly inside
|
||||
`widgets/` (no paths). Both are validated against
|
||||
`schema/manifest_schema.json`.
|
||||
|
||||
### Step 3: Reference Widget in Schema
|
||||
|
||||
In your plugin's `config_schema.json`:
|
||||
|
||||
@@ -383,15 +407,30 @@ In your plugin's `config_schema.json`:
|
||||
}
|
||||
```
|
||||
|
||||
### Step 3: Widget Loading
|
||||
### Step 4: Widget Loading
|
||||
|
||||
The widget will be automatically loaded when the plugin configuration form is rendered. The system will:
|
||||
The widget is loaded on demand when the plugin's configuration form renders a
|
||||
field that references it. The system will:
|
||||
|
||||
1. Check if widget is registered in the core registry
|
||||
2. If not found, attempt to load from plugin directory: `/static/plugin-widgets/[plugin-id]/[widget-name].js`
|
||||
3. Render the widget using the registered `render` function
|
||||
1. Check whether the widget is already registered in the core registry.
|
||||
2. If not, fetch it from `/static/plugin-widgets/[plugin-id]/[widget-name].js`.
|
||||
That route serves the declared `script` from your plugin's `widgets/`
|
||||
directory, as `text/javascript`.
|
||||
3. Render it by calling the `render` function your script registered.
|
||||
|
||||
**Note:** Currently, widgets are server-side rendered via Jinja2 templates. Custom widgets registered via the registry will have their handlers available, but full client-side rendering is a future enhancement.
|
||||
The fetch uses a dynamic `import()`, so the file must parse as an ES module.
|
||||
A plain IIFE does — modules are strict mode, so avoid sloppy-mode constructs.
|
||||
|
||||
**If the widget fails to load** (not declared, file missing, script throws, or
|
||||
it never calls `register`), the field falls back to a plain text input holding
|
||||
the current value. This is deliberate: a broken widget costs the user an
|
||||
editor, not their configured value.
|
||||
|
||||
**Limitation:** the on-demand path applies to `string`-typed fields (the
|
||||
default branch of the config-form renderer). Fields typed `object`, `array`,
|
||||
`boolean`, `integer` or `number`, and fields whose `enum` is set, are
|
||||
dispatched by the server-side template to its own built-in renderers, so a
|
||||
plugin-supplied `x-widget` on one of those is ignored today.
|
||||
|
||||
## Widget API Reference
|
||||
|
||||
@@ -497,10 +536,11 @@ See [`web_interface/static/v3/js/widgets/example-color-picker.js`](../web_interf
|
||||
- ✅ Plugin widget loading system implemented
|
||||
|
||||
**Current Behavior:**
|
||||
- Widgets are server-side rendered via Jinja2 templates (existing behavior preserved)
|
||||
- Core widgets are server-side rendered via Jinja2 templates (existing behavior preserved)
|
||||
- Widget handlers are registered and available globally
|
||||
- Custom widgets can be created and registered
|
||||
- Full client-side rendering is a future enhancement
|
||||
- Custom widgets can be created, declared in `manifest.json`, and are served
|
||||
and rendered on demand for `string`-typed fields
|
||||
- Plugin widgets on non-string fields are not dispatched yet (see Step 4)
|
||||
|
||||
**Backwards Compatibility:**
|
||||
- All existing plugins using widgets continue to work without changes
|
||||
|
||||
@@ -257,6 +257,30 @@
|
||||
},
|
||||
"description": "Web UI action definitions"
|
||||
},
|
||||
"widgets": {
|
||||
"type": "array",
|
||||
"description": "Custom web-UI widgets this plugin provides. Each entry is served at /static/plugin-widgets/<plugin-id>/<name>.js from the plugin's widgets/ directory; only declared widgets are served. Reference one from config_schema.json with \"x-widget\": \"<name>\".",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": ["name"],
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-zA-Z0-9_-]{1,64}$",
|
||||
"description": "Widget name, as used in x-widget and in the URL."
|
||||
},
|
||||
"script": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-zA-Z0-9_-]{1,64}\\.js$",
|
||||
"description": "Filename inside widgets/. Defaults to <name>.js."
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"description": "Human-readable summary shown to plugin authors."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"ledmatrix_version": {
|
||||
"type": "string",
|
||||
"description": "Deprecated: Use compatible_versions instead. LEDMatrix version this plugin targets"
|
||||
|
||||
+14
-14
@@ -99,18 +99,18 @@ def upcoming_center_mode(config: Optional[Dict[str, Any]]) -> str:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def element_color(config: Optional[Dict[str, Any]], element: str,
|
||||
default: Tuple[int, int, int] = (255, 255, 255)):
|
||||
"""Per-element text colour from customization.<element>.text_color."""
|
||||
try:
|
||||
cfg = (config or {}).get("customization", {}).get(element, {})
|
||||
value = cfg.get("text_color")
|
||||
if isinstance(value, (list, tuple)) and len(value) == 3:
|
||||
return tuple(max(0, min(255, int(c))) for c in value)
|
||||
if isinstance(value, str) and value.startswith("#") and len(value) == 7:
|
||||
return tuple(int(value[i:i + 2], 16) for i in (1, 3, 5))
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
return default
|
||||
default: Tuple[int, int, int] = (255, 255, 255),
|
||||
mode: Optional[str] = None):
|
||||
"""Per-element text colour from customization.<element>.text_color.
|
||||
|
||||
Delegated rather than reimplemented: there were two copies of this
|
||||
read and three of the offset read, and the shared one also resolves
|
||||
the element under the names plugins actually use (the layout block
|
||||
says `score` where the style block says `score_text`) and honours a
|
||||
per-mode override. Hex strings are still accepted.
|
||||
"""
|
||||
from src.element_style import element_color as _shared
|
||||
return _shared(config, element, default, mode)
|
||||
|
||||
|
||||
def font_color(config: Optional[Dict[str, Any]], fonts: Optional[Dict[str, Any]],
|
||||
@@ -448,7 +448,7 @@ def unshare_element_fonts(logger, fonts):
|
||||
path) are left shared, and their draws stay white as before.
|
||||
"""
|
||||
try:
|
||||
from PIL import ImageFont as _IF
|
||||
from src.common.font_layout import load_truetype as _load
|
||||
except ImportError: # pragma: no cover
|
||||
return fonts
|
||||
seen = {}
|
||||
@@ -463,7 +463,7 @@ def unshare_element_fonts(logger, fonts):
|
||||
if not path or not size:
|
||||
continue
|
||||
try:
|
||||
fonts[key] = _IF.truetype(path, size)
|
||||
fonts[key] = _load(path, size)
|
||||
except (OSError, ValueError, TypeError):
|
||||
logger.debug(
|
||||
"Could not un-share the %s face; it keeps the default colour", key)
|
||||
|
||||
@@ -134,19 +134,9 @@ class SportsGameRendererMixin:
|
||||
the element on the scroll/Vegas card too -- previously the schema
|
||||
advertised these offsets but this renderer ignored them.
|
||||
"""
|
||||
try:
|
||||
layout = (self.config or {}).get("customization", {}).get("layout", {})
|
||||
value = (layout.get(element) or {}).get(axis, default)
|
||||
if isinstance(value, bool):
|
||||
return default
|
||||
if isinstance(value, (int, float)):
|
||||
return int(value) if math.isfinite(value) else default
|
||||
if isinstance(value, str):
|
||||
parsed = float(value)
|
||||
return int(parsed) if math.isfinite(parsed) else default
|
||||
except (TypeError, ValueError, OverflowError):
|
||||
pass
|
||||
return default
|
||||
from src.element_style import layout_offset
|
||||
return layout_offset(self.config, element, axis, default,
|
||||
getattr(self, "SKIN_MODE", None))
|
||||
|
||||
# ---- upcoming cards ------------------------------------------------
|
||||
|
||||
|
||||
+26
-13
@@ -757,18 +757,31 @@ class SportsCoreSharedMixin:
|
||||
self.logger.debug("Headline font scaling skipped", exc_info=True)
|
||||
return fonts
|
||||
|
||||
def _get_layout_offset(self, element: str, axis: str,
|
||||
default: int = 0) -> int:
|
||||
"""X/Y nudge for one element, from ``customization.layout``.
|
||||
|
||||
Promoted here so every scoreboard reads offsets the same way the
|
||||
scroll card does. Each plugin still carries its own copy in its
|
||||
bundled sports.py, which wins by MRO until that copy is deleted --
|
||||
deleting it is what buys the alias handling (a plugin asking for
|
||||
``score_text`` finds the ``score`` its users configured) and the
|
||||
per-mode overrides, since this resolves through SKIN_MODE.
|
||||
"""
|
||||
from src.element_style import layout_offset
|
||||
return layout_offset(self.config, element, axis, default,
|
||||
getattr(self, "SKIN_MODE", None))
|
||||
|
||||
def _element_color(self, element: str, default: Tuple[int, int, int] = (255, 255, 255)):
|
||||
"""Per-element text colour from customization.<element>.text_color."""
|
||||
try:
|
||||
cfg = (self.config or {}).get("customization", {}).get(element, {})
|
||||
value = cfg.get("text_color")
|
||||
if isinstance(value, (list, tuple)) and len(value) == 3:
|
||||
return tuple(max(0, min(255, int(c))) for c in value)
|
||||
if isinstance(value, str) and value.startswith("#") and len(value) == 7:
|
||||
return tuple(int(value[i:i + 2], 16) for i in (1, 3, 5))
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
return default
|
||||
"""Per-element text colour from customization.<element>.text_color.
|
||||
|
||||
Mode-aware through SKIN_MODE, so Live and Recent instances of the
|
||||
same scoreboard resolve their own colours without any call site
|
||||
passing a mode.
|
||||
"""
|
||||
from src.element_style import element_color as _shared
|
||||
return _shared(self.config, element, default,
|
||||
getattr(self, "SKIN_MODE", None))
|
||||
|
||||
def _unshare_element_fonts(self, fonts):
|
||||
"""Give each colourable element its own face object.
|
||||
@@ -787,7 +800,7 @@ class SportsCoreSharedMixin:
|
||||
path) are left shared, and their draws stay white as before.
|
||||
"""
|
||||
try:
|
||||
from PIL import ImageFont as _IF
|
||||
from src.common.font_layout import load_truetype as _load
|
||||
except ImportError: # pragma: no cover
|
||||
return fonts
|
||||
seen = {}
|
||||
@@ -802,7 +815,7 @@ class SportsCoreSharedMixin:
|
||||
if not path or not size:
|
||||
continue
|
||||
try:
|
||||
fonts[key] = _IF.truetype(path, size)
|
||||
fonts[key] = _load(path, size)
|
||||
except (OSError, ValueError, TypeError):
|
||||
self.logger.debug(
|
||||
"Could not un-share the %s face; it keeps the default colour", key)
|
||||
|
||||
+890
-76
File diff suppressed because it is too large
Load Diff
@@ -12,11 +12,47 @@ from abc import ABC, abstractmethod
|
||||
from enum import Enum
|
||||
from typing import Dict, Any, Optional, List
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from src.logging_config import get_logger
|
||||
|
||||
|
||||
_shared_fallback_font_manager: Optional[Any] = None
|
||||
|
||||
#: Distinguishes "not looked up yet" from "looked up and not found", so a
|
||||
#: plugin with no schema does not re-scan the disk on every frame.
|
||||
_UNSET_SCHEMA_PATH = object()
|
||||
|
||||
|
||||
class _NullStyleResolver:
|
||||
"""Stand-in for ElementStyleResolver when the module is unavailable.
|
||||
|
||||
Only reachable on a core that predates src.element_style, which
|
||||
``styles`` degrades to rather than raising: every lookup returns the
|
||||
caller's classic values, which is what the plugin drew before styling
|
||||
existed.
|
||||
"""
|
||||
|
||||
def __init__(self, config: Any) -> None:
|
||||
self._config = config
|
||||
|
||||
def style(self, element_key: str, classic_font: str = None,
|
||||
classic_size: int = 8, classic_color: Any = None,
|
||||
mode: Optional[str] = None) -> Any:
|
||||
from types import SimpleNamespace
|
||||
return SimpleNamespace(
|
||||
font=None, color=classic_color or (255, 255, 255), offset=(0, 0),
|
||||
font_name=classic_font, font_size=classic_size,
|
||||
user_forced=False, user_forced_color=False,
|
||||
visible=True, align=None, scale=1.0)
|
||||
|
||||
def offset(self, element_key: str, mode: Optional[str] = None) -> tuple:
|
||||
return (0, 0)
|
||||
|
||||
def offset_value(self, element_key: str, axis: str, default: int = 0,
|
||||
mode: Optional[str] = None) -> int:
|
||||
return default
|
||||
|
||||
|
||||
def _fallback_font_manager() -> Any:
|
||||
"""Shared FontManager for environments (unit tests, mocks) where the
|
||||
@@ -63,6 +99,12 @@ class BasePlugin(ABC):
|
||||
|
||||
API_VERSION = "1.0.0"
|
||||
|
||||
#: Which ``customization.modes.<mode>`` overrides :attr:`styles` applies.
|
||||
#: A plugin with one instance per display mode (the scoreboards' live /
|
||||
#: upcoming / recent classes) sets this and every existing style lookup
|
||||
#: becomes mode-aware without changing a call site.
|
||||
STYLE_MODE: Optional[str] = None
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
plugin_id: str,
|
||||
@@ -260,6 +302,128 @@ class BasePlugin(ABC):
|
||||
self._layout_font_generation = generation
|
||||
return context
|
||||
|
||||
@property
|
||||
def styles(self) -> Any:
|
||||
"""
|
||||
The user's per-element styling: fonts, sizes, colours, offsets,
|
||||
visibility, alignment and scale, resolved against this plugin's own
|
||||
config_schema.json.
|
||||
|
||||
Every consumer of src.element_style used to repeat the same three
|
||||
things -- a guarded import, finding its own schema file, and
|
||||
rebuilding the resolver when on_config_change swapped the config
|
||||
dict. This is those three things, once.
|
||||
|
||||
Ask for a style by element name, passing what the plugin drew before
|
||||
the user could customise anything::
|
||||
|
||||
title = self.styles.style('title_text',
|
||||
classic_font='PressStart2P-Regular.ttf',
|
||||
classic_size=8,
|
||||
classic_color=(255, 255, 255))
|
||||
x, y = title.offset
|
||||
self.display_manager.draw_text(text, x=x, y=y,
|
||||
font=title.font, color=title.color)
|
||||
|
||||
The classic_* arguments matter: when the user has chosen nothing,
|
||||
they come back verbatim, so a plugin that adopts this renders
|
||||
identically until someone actually changes a setting.
|
||||
|
||||
A plugin whose display has modes (a scoreboard's live/upcoming/
|
||||
recent, weather's current/hourly/daily) sets ``STYLE_MODE`` on the
|
||||
class, and every lookup here honours the matching
|
||||
``customization.modes.<mode>`` overrides without any call site
|
||||
passing a mode. Use :meth:`styles_for` for a one-off mode.
|
||||
|
||||
Never raises: with no schema on disk, or with the element-style
|
||||
module unavailable, lookups fall back to the classic values.
|
||||
"""
|
||||
resolver = getattr(self, "_style_resolver", None)
|
||||
# The config dict is swapped wholesale by on_config_change, so
|
||||
# identity is the invalidation signal -- the same check the sports
|
||||
# base classes use.
|
||||
if resolver is not None and resolver._config is self.config:
|
||||
return resolver
|
||||
resolver = self._build_style_resolver(getattr(self, "STYLE_MODE", None))
|
||||
self._style_resolver = resolver
|
||||
return resolver
|
||||
|
||||
def styles_for(self, mode: Optional[str]) -> Any:
|
||||
""":attr:`styles`, bound to ``mode`` instead of ``STYLE_MODE``.
|
||||
|
||||
For a plugin that renders more than one mode from one instance. A
|
||||
plugin with an instance per mode should set ``STYLE_MODE`` instead
|
||||
and leave its call sites alone.
|
||||
"""
|
||||
cache = getattr(self, "_style_resolvers_by_mode", None)
|
||||
if cache is None or getattr(self, "_style_resolver_config", None) is not self.config:
|
||||
cache = {}
|
||||
self._style_resolvers_by_mode = cache
|
||||
self._style_resolver_config = self.config
|
||||
if mode not in cache:
|
||||
cache[mode] = self._build_style_resolver(mode)
|
||||
return cache[mode]
|
||||
|
||||
def _build_style_resolver(self, mode: Optional[str]) -> Any:
|
||||
"""Construct a resolver for this plugin's config and schema."""
|
||||
try:
|
||||
from src.element_style import (ElementStyleResolver,
|
||||
defaults_from_schema_file)
|
||||
except ImportError: # pragma: no cover - core always ships it
|
||||
return _NullStyleResolver(self.config)
|
||||
|
||||
schema_path = self._config_schema_path()
|
||||
defaults = (defaults_from_schema_file(schema_path) if schema_path
|
||||
else {})
|
||||
return ElementStyleResolver(self.config, defaults, mode=mode)
|
||||
|
||||
def _config_schema_path(self) -> Optional[str]:
|
||||
"""This plugin's config_schema.json, or None.
|
||||
|
||||
Looked up from the concrete class's own module rather than from this
|
||||
file: a plugin's subclass lives in its plugin directory, while this
|
||||
module lives in src/plugin_system, where no plugin schema exists.
|
||||
Falls back to the configured plugins directory, including the
|
||||
ledmatrix- prefix form the loader accepts.
|
||||
|
||||
Returning None is safe, not fatal -- the resolver then has no
|
||||
defaults to compare against, so every configured value counts as a
|
||||
deliberate override, which is the conservative reading.
|
||||
"""
|
||||
cached = getattr(self, "_config_schema_path_cache", _UNSET_SCHEMA_PATH)
|
||||
if cached is not _UNSET_SCHEMA_PATH:
|
||||
return cached
|
||||
|
||||
path = None
|
||||
try:
|
||||
for candidate in self._schema_path_candidates():
|
||||
if candidate and os.path.isfile(candidate):
|
||||
path = candidate
|
||||
break
|
||||
except Exception as exc: # pragma: no cover - defensive
|
||||
self.logger.debug("Could not locate config_schema.json: %s", exc)
|
||||
self._config_schema_path_cache = path
|
||||
return path
|
||||
|
||||
def _schema_path_candidates(self) -> list:
|
||||
"""Where a plugin's schema might be, best guess first."""
|
||||
candidates = []
|
||||
|
||||
module = sys.modules.get(type(self).__module__)
|
||||
module_file = getattr(module, "__file__", None)
|
||||
if module_file:
|
||||
candidates.append(os.path.join(
|
||||
os.path.dirname(os.path.abspath(module_file)),
|
||||
"config_schema.json"))
|
||||
|
||||
plugins_dir = getattr(self.plugin_manager, "plugins_dir", None)
|
||||
if plugins_dir:
|
||||
for plugin_id in (self.plugin_id, f"ledmatrix-{self.plugin_id}"):
|
||||
candidates.append(os.path.join(
|
||||
str(plugins_dir), os.path.basename(plugin_id),
|
||||
"config_schema.json"))
|
||||
return candidates
|
||||
|
||||
def draw_fit(self, text: str, box: Any,
|
||||
color: tuple = (255, 255, 255),
|
||||
ladder: Optional[Any] = None,
|
||||
|
||||
Vendored
-25
@@ -246,31 +246,6 @@
|
||||
"OPTIONS"
|
||||
]
|
||||
],
|
||||
[
|
||||
"/api/v3/fonts/overrides",
|
||||
"api_v3.get_fonts_overrides",
|
||||
[
|
||||
"GET",
|
||||
"HEAD",
|
||||
"OPTIONS"
|
||||
]
|
||||
],
|
||||
[
|
||||
"/api/v3/fonts/overrides",
|
||||
"api_v3.save_fonts_overrides",
|
||||
[
|
||||
"OPTIONS",
|
||||
"POST"
|
||||
]
|
||||
],
|
||||
[
|
||||
"/api/v3/fonts/overrides/<element_key>",
|
||||
"api_v3.delete_font_override",
|
||||
[
|
||||
"DELETE",
|
||||
"OPTIONS"
|
||||
]
|
||||
],
|
||||
[
|
||||
"/api/v3/fonts/preview",
|
||||
"api_v3.get_font_preview",
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
"""BasePlugin.styles -- the per-element styling seam every plugin inherits.
|
||||
|
||||
Before this, each consumer of src.element_style repeated the same three
|
||||
things: a guarded import, finding its own config_schema.json, and rebuilding
|
||||
the resolver when on_config_change swapped the config dict. Getting the
|
||||
second one wrong is silent -- the resolver simply has no defaults to compare
|
||||
against, so every configured value reads as a deliberate override and the
|
||||
plugin quietly stops honouring its own shipped styling.
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
import types
|
||||
|
||||
import pytest
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from src.plugin_system.base_plugin import BasePlugin
|
||||
|
||||
SCHEMA = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"customization": {
|
||||
"type": "object",
|
||||
"x-style-modes": ["live", "recent"],
|
||||
"x-style-elements": {
|
||||
"score_text": {
|
||||
"title": "Score",
|
||||
"font": {"default": "PressStart2P-Regular.ttf"},
|
||||
"size": {"default": 10, "min": 4, "max": 16},
|
||||
"color": {"default": [255, 255, 255]},
|
||||
"offsets": True,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _plugin_class(module_name, module_file, **attrs):
|
||||
"""A plugin class whose module sits in a plugin directory, as a real
|
||||
one does -- that is what schema discovery keys on."""
|
||||
module = types.ModuleType(module_name)
|
||||
module.__file__ = str(module_file)
|
||||
sys.modules[module_name] = module
|
||||
|
||||
namespace = dict(attrs)
|
||||
namespace["update"] = lambda self: None
|
||||
namespace["display"] = lambda self, force_clear=False: None
|
||||
cls = type("DemoPlugin", (BasePlugin,), namespace)
|
||||
cls.__module__ = module_name
|
||||
return cls
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def plugin_dir(tmp_path):
|
||||
d = tmp_path / "demo"
|
||||
d.mkdir()
|
||||
(d / "config_schema.json").write_text(json.dumps(SCHEMA), encoding="utf-8")
|
||||
return d
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def make(tmp_path, plugin_dir, request):
|
||||
created = []
|
||||
|
||||
def _make(config, plugin_id="demo", schema_in_module_dir=True, **attrs):
|
||||
name = f"demo_module_{len(created)}_{id(request)}"
|
||||
module_file = (plugin_dir if schema_in_module_dir
|
||||
else tmp_path / "elsewhere") / "manager.py"
|
||||
module_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
cls = _plugin_class(name, module_file, **attrs)
|
||||
created.append(name)
|
||||
pm = MagicMock()
|
||||
pm.plugins_dir = str(tmp_path)
|
||||
return cls(plugin_id, config, MagicMock(), MagicMock(), pm)
|
||||
|
||||
try:
|
||||
yield _make
|
||||
finally:
|
||||
for name in created:
|
||||
sys.modules.pop(name, None)
|
||||
|
||||
|
||||
CONFIG = {"customization": {
|
||||
"score_text": {"font_size": 12},
|
||||
"layout": {"score_text": {"y_offset": -3}},
|
||||
"modes": {"live": {"score_text": {"font_size": 16}}},
|
||||
}}
|
||||
|
||||
|
||||
class TestSchemaDiscovery:
|
||||
def test_it_finds_the_schema_beside_the_plugin_module(self, make, plugin_dir):
|
||||
"""Not beside base_plugin.py, which lives in src/plugin_system where
|
||||
no plugin schema exists."""
|
||||
p = make({})
|
||||
assert p._config_schema_path() == str(plugin_dir / "config_schema.json")
|
||||
|
||||
def test_it_falls_back_to_the_plugins_directory(self, make):
|
||||
p = make({}, schema_in_module_dir=False)
|
||||
assert p._config_schema_path() is not None
|
||||
|
||||
def test_it_accepts_the_ledmatrix_prefix_form(self, tmp_path, make):
|
||||
(tmp_path / "ledmatrix-music").mkdir()
|
||||
(tmp_path / "ledmatrix-music" / "config_schema.json").write_text(
|
||||
json.dumps(SCHEMA), encoding="utf-8")
|
||||
p = make({}, plugin_id="music", schema_in_module_dir=False)
|
||||
assert "ledmatrix-music" in p._config_schema_path()
|
||||
|
||||
def test_a_missing_schema_is_not_fatal(self, tmp_path, make):
|
||||
p = make({}, plugin_id="ghost", schema_in_module_dir=False)
|
||||
# 'elsewhere' has no schema and neither does tmp_path/ghost
|
||||
assert p._config_schema_path() is None
|
||||
assert p.styles.style("score_text", classic_size=8).font_size == 8
|
||||
|
||||
def test_the_module_directory_wins_over_the_plugins_directory(
|
||||
self, tmp_path, plugin_dir):
|
||||
"""These two are the same path for an installed plugin, so the test
|
||||
has to force them apart -- and they genuinely diverge for a plugin
|
||||
symlinked in for development, where the module lives outside the
|
||||
configured plugins directory. Sourcing the path from this module's
|
||||
own __file__ instead would find neither, then quietly fall through
|
||||
to whatever the plugins directory happened to hold.
|
||||
"""
|
||||
dev_dir = tmp_path / "dev-checkout"
|
||||
dev_dir.mkdir()
|
||||
dev_schema = json.loads(json.dumps(SCHEMA))
|
||||
(dev_schema["properties"]["customization"]["x-style-elements"]
|
||||
["score_text"]["size"]["default"]) = 99
|
||||
(dev_dir / "config_schema.json").write_text(json.dumps(dev_schema),
|
||||
encoding="utf-8")
|
||||
|
||||
cls = _plugin_class("demo_dev_checkout", dev_dir / "manager.py")
|
||||
try:
|
||||
pm = MagicMock()
|
||||
pm.plugins_dir = str(tmp_path) # holds demo/config_schema.json
|
||||
p = cls("demo", {}, MagicMock(), MagicMock(), pm)
|
||||
assert p._config_schema_path() == str(dev_dir / "config_schema.json")
|
||||
# and the difference is observable: 99 is this schema's default,
|
||||
# so configuring 99 must read as "not a choice".
|
||||
p.on_config_change({"customization": {"score_text": {"font_size": 99}}})
|
||||
assert p.styles.style("score_text", classic_size=10).font_size == 10
|
||||
finally:
|
||||
sys.modules.pop("demo_dev_checkout", None)
|
||||
|
||||
def test_the_lookup_is_cached(self, make):
|
||||
"""A miss must not re-scan the disk on every frame."""
|
||||
p = make({})
|
||||
first = p._config_schema_path()
|
||||
assert p._config_schema_path() is first
|
||||
|
||||
|
||||
class TestResolution:
|
||||
def test_an_untouched_config_gets_the_classic_values(self, make):
|
||||
"""The invariant that makes adopting this safe."""
|
||||
style = make({}).styles.style(
|
||||
"score_text", classic_font="4x6-font.ttf", classic_size=6,
|
||||
classic_color=(1, 2, 3))
|
||||
assert (style.font_name, style.font_size, style.color) == (
|
||||
"4x6-font.ttf", 6, (1, 2, 3))
|
||||
assert style.user_forced is False
|
||||
|
||||
def test_a_users_choice_comes_through(self, make):
|
||||
style = make(CONFIG).styles.style("score_text", classic_size=10)
|
||||
assert style.font_size == 12
|
||||
assert style.offset == (0, -3)
|
||||
|
||||
def test_style_mode_binds_without_touching_call_sites(self, make):
|
||||
"""The whole point: a per-mode subclass sets one attribute and its
|
||||
existing lookups become mode-aware."""
|
||||
assert make(CONFIG).styles.style(
|
||||
"score_text", classic_size=10).font_size == 12
|
||||
assert make(CONFIG, STYLE_MODE="live").styles.style(
|
||||
"score_text", classic_size=10).font_size == 16
|
||||
|
||||
def test_styles_for_handles_a_one_off_mode(self, make):
|
||||
p = make(CONFIG)
|
||||
assert p.styles_for("live").style("score_text", classic_size=10).font_size == 16
|
||||
assert p.styles_for("recent").style("score_text", classic_size=10).font_size == 12
|
||||
assert p.styles_for(None).style("score_text", classic_size=10).font_size == 12
|
||||
|
||||
|
||||
class TestInvalidation:
|
||||
def test_the_resolver_is_reused(self, make):
|
||||
p = make(CONFIG)
|
||||
assert p.styles is p.styles
|
||||
|
||||
def test_a_config_swap_rebuilds_it(self, make):
|
||||
"""on_config_change replaces the dict rather than mutating it, so
|
||||
identity is the signal."""
|
||||
p = make(CONFIG)
|
||||
assert p.styles.style("score_text", classic_size=10).font_size == 12
|
||||
p.on_config_change({"customization": {"score_text": {"font_size": 6}}})
|
||||
assert p.styles.style("score_text", classic_size=10).font_size == 6
|
||||
|
||||
def test_a_config_swap_rebuilds_the_per_mode_cache_too(self, make):
|
||||
p = make(CONFIG)
|
||||
assert p.styles_for("live").style("score_text", classic_size=10).font_size == 16
|
||||
p.on_config_change({"customization": {
|
||||
"modes": {"live": {"score_text": {"font_size": 5}}}}})
|
||||
assert p.styles_for("live").style("score_text", classic_size=10).font_size == 5
|
||||
|
||||
|
||||
class TestDegradation:
|
||||
# config=None is not in this list on purpose: BasePlugin.__init__ reads
|
||||
# config.get("enabled") before styles exists, so a None config is a
|
||||
# constructor precondition rather than something styles should absorb.
|
||||
@pytest.mark.parametrize("config", [
|
||||
{}, {"customization": None}, {"customization": "nonsense"},
|
||||
{"customization": {"score_text": "nonsense"}},
|
||||
{"customization": {"layout": "nonsense"}},
|
||||
{"customization": {"modes": "nonsense"}},
|
||||
])
|
||||
def test_a_hostile_config_still_resolves(self, config, make):
|
||||
style = make(config).styles.style("score_text", classic_size=7)
|
||||
assert style.font_size == 7
|
||||
|
||||
def test_it_works_without_a_plugin_manager(self, plugin_dir):
|
||||
cls = _plugin_class("demo_no_pm", plugin_dir / "manager.py")
|
||||
try:
|
||||
p = cls("demo", {}, MagicMock(), MagicMock(), None)
|
||||
assert p.styles.style("score_text", classic_size=8).font_size == 8
|
||||
finally:
|
||||
sys.modules.pop("demo_no_pm", None)
|
||||
+862
-2
@@ -16,6 +16,7 @@ ledmatrix-music, football-scoreboard):
|
||||
- style() never raises; malformed input degrades to the classic style.
|
||||
"""
|
||||
|
||||
import copy
|
||||
import json
|
||||
import os
|
||||
|
||||
@@ -23,7 +24,10 @@ import pytest
|
||||
from PIL import ImageFont
|
||||
|
||||
from src.element_style import (
|
||||
alias_keys,
|
||||
ElementStyleResolver,
|
||||
_load_font_sized,
|
||||
native_bdf_size,
|
||||
defaults_from_schema,
|
||||
defaults_from_schema_file,
|
||||
expand_style_elements,
|
||||
@@ -173,10 +177,25 @@ class TestExpandStyleElements:
|
||||
expand_style_elements(STYLE_ELEMENTS_SCHEMA)
|
||||
assert json.dumps(STYLE_ELEMENTS_SCHEMA, sort_keys=True) == before
|
||||
|
||||
def test_no_declaration_returns_same_object(self):
|
||||
assert expand_style_elements(MANUAL_SCHEMA) is MANUAL_SCHEMA
|
||||
def test_a_schema_with_nothing_to_expand_is_returned_as_is(self):
|
||||
"""No needless deep copy when there is nothing to do."""
|
||||
empty = {"properties": {}}
|
||||
assert expand_style_elements(empty) is empty
|
||||
no_style = {"properties": {"customization": {"type": "object",
|
||||
"properties": {"favorite_result_colors": {"type": "object",
|
||||
"properties": {"win_color": {"type": "array"}}}}}}}
|
||||
assert expand_style_elements(no_style) is no_style
|
||||
|
||||
def test_a_hand_written_block_is_adopted(self):
|
||||
"""Nineteen plugins spell their style elements out longhand rather
|
||||
than declaring them, and predate this system entirely. They pick up
|
||||
the editor and the font picker on a core update rather than on a
|
||||
plugin release."""
|
||||
expanded = expand_style_elements(MANUAL_SCHEMA)
|
||||
assert expanded is not MANUAL_SCHEMA
|
||||
customization = expanded["properties"]["customization"]
|
||||
assert customization["x-widget"] == "style-editor"
|
||||
assert "x-style-elements" not in MANUAL_SCHEMA["properties"]["customization"]
|
||||
|
||||
def test_garbage_input_never_raises(self):
|
||||
bad = {"properties": {"customization": {"x-style-elements": "nope"}}}
|
||||
@@ -340,6 +359,19 @@ class TestDegradation:
|
||||
# The override IS honored as forced, but the face degrades safely.
|
||||
assert style.user_forced
|
||||
assert style.font is not None
|
||||
assert not isinstance(style.font, tuple)
|
||||
|
||||
def test_missing_font_load_returns_a_font_not_a_nested_tuple(self):
|
||||
# Regression: the missing-font path in _load_font_sized used to wrap
|
||||
# _load_fallback_font's own (font, size) tuple a second time, so
|
||||
# load_font() returned a (font, size) tuple where callers (draw.text,
|
||||
# layout math) expect a font object.
|
||||
font, size = _load_font_sized("definitely-not-a-real-font.ttf", 8)
|
||||
assert not isinstance(font, tuple)
|
||||
assert size == 8
|
||||
|
||||
single = load_font("definitely-not-a-real-font.ttf", 8)
|
||||
assert not isinstance(single, tuple)
|
||||
|
||||
def test_empty_defaults_treats_config_as_reference_to_classic(self):
|
||||
# No schema defaults at all: a config value equal to the classic
|
||||
@@ -410,3 +442,831 @@ class TestResolverPlumbing:
|
||||
cust = schema["properties"]["customization"]["properties"]
|
||||
assert cust["title_text"]["x-style-managed"] is True
|
||||
assert "title_text" in cust["layout"]["properties"]
|
||||
class TestBdfSizing:
|
||||
"""A BDF is a fixed-size bitmap strike, not a scalable outline.
|
||||
|
||||
FreeType accepts only the exact pixel size baked into the file and raises
|
||||
for anything else, and 32 of the 35 shipped fonts are BDF -- so a size
|
||||
picked in the web UI usually is not a valid strike. This used to fall
|
||||
through to the generic font-load except and return *PressStart2P*, so
|
||||
asking for 5x7.bdf at size 10 silently rendered a different typeface.
|
||||
It now falls back to the file's own size instead, matching what
|
||||
SportsCore._load_custom_font_from_element_config already did.
|
||||
"""
|
||||
|
||||
def test_a_valid_strike_loads_at_that_size(self):
|
||||
import freetype
|
||||
font, realised = _load_font_sized("5x7.bdf", 7)
|
||||
assert isinstance(font, freetype.Face)
|
||||
assert realised == 7
|
||||
|
||||
@pytest.mark.parametrize("requested", [4, 10, 16, 32])
|
||||
def test_a_wrong_size_keeps_the_font_and_snaps_the_size(self, requested):
|
||||
"""The regression: the face must still be 5x7, not a substitute."""
|
||||
import freetype
|
||||
font, realised = _load_font_sized("5x7.bdf", requested)
|
||||
assert isinstance(font, freetype.Face), (
|
||||
"a BDF asked for a bad size used to come back as a PIL "
|
||||
"PressStart2P face -- a different font entirely")
|
||||
assert realised == 7
|
||||
|
||||
def test_the_reported_size_is_what_was_realised(self, style_schema_path):
|
||||
"""ElementStyle.font_size drives caller layout, so it must not report
|
||||
a size nothing was drawn at."""
|
||||
config = {"customization": {"title_text": {"font": "5x7.bdf",
|
||||
"font_size": 20}}}
|
||||
style = ElementStyleResolver(
|
||||
config, defaults_from_schema_file(style_schema_path)
|
||||
).style("title_text", classic_font="PressStart2P-Regular.ttf",
|
||||
classic_size=8)
|
||||
assert style.font_name == "5x7.bdf"
|
||||
assert style.font_size == 7
|
||||
|
||||
def test_native_bdf_size_reads_the_file(self):
|
||||
assert native_bdf_size("5x7.bdf") == 7
|
||||
|
||||
@pytest.mark.parametrize("name", ["PressStart2P-Regular.ttf",
|
||||
"no-such-font.bdf", "", None])
|
||||
def test_native_bdf_size_is_none_when_size_is_a_free_choice(self, name):
|
||||
"""None is the web UI's signal that the size field stays editable."""
|
||||
assert native_bdf_size(name) is None
|
||||
|
||||
def test_a_scalable_font_realises_the_requested_size(self):
|
||||
for size in (6, 8, 13):
|
||||
font, realised = _load_font_sized("PressStart2P-Regular.ttf", size)
|
||||
assert realised == size
|
||||
assert not isinstance(font, type(None))
|
||||
|
||||
|
||||
class TestFontCacheIsBounded:
|
||||
"""The display process runs for weeks and every config save can add a new
|
||||
(font, size) pair; this cache was unbounded, against the house style of
|
||||
every other hot cache in the codebase."""
|
||||
|
||||
def test_the_cache_evicts_past_its_bound(self):
|
||||
import src.element_style as es
|
||||
es._font_cache.clear()
|
||||
try:
|
||||
for size in range(1, es._FONT_CACHE_MAX + 40):
|
||||
load_font("PressStart2P-Regular.ttf", size)
|
||||
assert len(es._font_cache) <= es._FONT_CACHE_MAX
|
||||
finally:
|
||||
es._font_cache.clear()
|
||||
|
||||
def test_a_repeated_load_is_the_same_object(self):
|
||||
import src.element_style as es
|
||||
es._font_cache.clear()
|
||||
try:
|
||||
first = load_font("PressStart2P-Regular.ttf", 8)
|
||||
assert load_font("PressStart2P-Regular.ttf", 8) is first
|
||||
finally:
|
||||
es._font_cache.clear()
|
||||
|
||||
def test_eviction_is_least_recently_used(self):
|
||||
import src.element_style as es
|
||||
es._font_cache.clear()
|
||||
try:
|
||||
oldest = load_font("PressStart2P-Regular.ttf", 1)
|
||||
for size in range(2, es._FONT_CACHE_MAX + 1):
|
||||
load_font("PressStart2P-Regular.ttf", size)
|
||||
# Touch the oldest so it is no longer the eviction candidate,
|
||||
# then overflow by one.
|
||||
assert load_font("PressStart2P-Regular.ttf", 1) is oldest
|
||||
load_font("PressStart2P-Regular.ttf", es._FONT_CACHE_MAX + 1)
|
||||
assert load_font("PressStart2P-Regular.ttf", 1) is oldest
|
||||
finally:
|
||||
es._font_cache.clear()
|
||||
|
||||
class TestModes:
|
||||
"""Per-mode overrides: one element styled differently per situation.
|
||||
|
||||
The motivating case is a scoreboard, where the score wants a bigger font
|
||||
on a live card than on an upcoming one. Live/Upcoming/Recent are already
|
||||
separate instances with distinct SKIN_MODE values, so the mode is bound
|
||||
to the resolver rather than threaded through every call site.
|
||||
|
||||
A mode layer is pure override: its fields default to None, meaning
|
||||
inherit. That is why None and 0 must stay distinct -- a mode y_offset of
|
||||
0 means "sit at the base position", not "no preference".
|
||||
"""
|
||||
|
||||
def _resolver(self, config, mode=None, schema=None):
|
||||
defaults = defaults_from_schema_file(schema) if schema else {}
|
||||
return ElementStyleResolver(config, defaults, mode=mode)
|
||||
|
||||
def test_no_mode_block_resolves_exactly_as_before(self, style_schema_path):
|
||||
config = {"customization": {"title_text": {"font_size": 12}}}
|
||||
plain = self._resolver(config, schema=style_schema_path)
|
||||
moded = self._resolver(config, mode="live", schema=style_schema_path)
|
||||
a = plain.style("title_text", classic_size=8)
|
||||
b = moded.style("title_text", classic_size=8)
|
||||
assert (a.font_name, a.font_size, a.color) == (b.font_name, b.font_size,
|
||||
b.color)
|
||||
|
||||
def test_a_mode_overrides_the_base_size(self, style_schema_path):
|
||||
config = {"customization": {
|
||||
"title_text": {"font_size": 12},
|
||||
"modes": {"live": {"title_text": {"font_size": 16}}}}}
|
||||
r = self._resolver(config, mode="live", schema=style_schema_path)
|
||||
assert r.style("title_text", classic_size=8).font_size == 16
|
||||
assert r.style("title_text", classic_size=8).user_forced is True
|
||||
|
||||
def test_a_different_mode_is_unaffected(self, style_schema_path):
|
||||
config = {"customization": {
|
||||
"title_text": {"font_size": 12},
|
||||
"modes": {"live": {"title_text": {"font_size": 16}}}}}
|
||||
r = self._resolver(config, mode="upcoming", schema=style_schema_path)
|
||||
assert r.style("title_text", classic_size=8).font_size == 12
|
||||
|
||||
def test_two_resolvers_share_a_config_and_differ_by_mode(
|
||||
self, style_schema_path):
|
||||
"""The SportsUpcoming / SportsRecent case: same config dict, two
|
||||
instances, two answers."""
|
||||
config = {"customization": {
|
||||
"title_text": {"font_size": 12},
|
||||
"modes": {"live": {"title_text": {"font_size": 16}},
|
||||
"recent": {"title_text": {"font_size": 6}}}}}
|
||||
live = self._resolver(config, mode="live", schema=style_schema_path)
|
||||
recent = self._resolver(config, mode="recent", schema=style_schema_path)
|
||||
assert live.style("title_text", classic_size=8).font_size == 16
|
||||
assert recent.style("title_text", classic_size=8).font_size == 6
|
||||
|
||||
def test_a_mode_overrides_font_and_colour(self, style_schema_path):
|
||||
config = {"customization": {
|
||||
"modes": {"live": {"title_text": {"font": "4x6-font.ttf",
|
||||
"text_color": [1, 2, 3]}}}}}
|
||||
st = self._resolver(config, mode="live",
|
||||
schema=style_schema_path).style(
|
||||
"title_text", classic_font="PressStart2P-Regular.ttf",
|
||||
classic_color=(255, 255, 255))
|
||||
assert st.font_name == "4x6-font.ttf"
|
||||
assert st.color == (1, 2, 3)
|
||||
assert st.user_forced and st.user_forced_color
|
||||
|
||||
def test_an_unset_mode_field_inherits_rather_than_resetting(
|
||||
self, style_schema_path):
|
||||
"""Only font_size is overridden; the colour must survive."""
|
||||
config = {"customization": {
|
||||
"title_text": {"text_color": [9, 9, 9]},
|
||||
"modes": {"live": {"title_text": {"font_size": 16}}}}}
|
||||
st = self._resolver(config, mode="live",
|
||||
schema=style_schema_path).style(
|
||||
"title_text", classic_color=(255, 255, 255))
|
||||
assert st.font_size == 16
|
||||
assert st.color == (9, 9, 9)
|
||||
|
||||
def test_a_null_mode_field_means_inherit(self, style_schema_path):
|
||||
config = {"customization": {
|
||||
"title_text": {"font_size": 12},
|
||||
"modes": {"live": {"title_text": {"font_size": None}}}}}
|
||||
st = self._resolver(config, mode="live",
|
||||
schema=style_schema_path).style("title_text")
|
||||
assert st.font_size == 12
|
||||
|
||||
def test_a_per_call_mode_overrides_the_bound_one(self, style_schema_path):
|
||||
config = {"customization": {"modes": {
|
||||
"live": {"title_text": {"font_size": 16}},
|
||||
"recent": {"title_text": {"font_size": 6}}}}}
|
||||
r = self._resolver(config, mode="live", schema=style_schema_path)
|
||||
assert r.style("title_text").font_size == 16
|
||||
assert r.style("title_text", mode="recent").font_size == 6
|
||||
|
||||
def test_the_memo_does_not_leak_between_modes(self, style_schema_path):
|
||||
config = {"customization": {"modes": {
|
||||
"live": {"title_text": {"font_size": 16}},
|
||||
"recent": {"title_text": {"font_size": 6}}}}}
|
||||
r = self._resolver(config, mode="live", schema=style_schema_path)
|
||||
assert r.style("title_text").font_size == 16
|
||||
assert r.style("title_text", mode="recent").font_size == 6
|
||||
assert r.style("title_text").font_size == 16
|
||||
|
||||
def test_mode_is_exposed(self):
|
||||
assert ElementStyleResolver({}, {}, mode="live").mode == "live"
|
||||
assert ElementStyleResolver({}, {}).mode is None
|
||||
|
||||
|
||||
class TestModeOffsets:
|
||||
def _r(self, config, mode=None):
|
||||
return ElementStyleResolver(config, {}, mode=mode)
|
||||
|
||||
def test_a_mode_offset_wins(self):
|
||||
config = {"customization": {
|
||||
"layout": {"score": {"y_offset": -2}},
|
||||
"modes": {"live": {"layout": {"score": {"y_offset": 5}}}}}}
|
||||
assert self._r(config, "live").offset_value("score", "y_offset") == 5
|
||||
|
||||
def test_an_absent_mode_offset_inherits_the_base(self):
|
||||
config = {"customization": {
|
||||
"layout": {"score": {"y_offset": -2}},
|
||||
"modes": {"live": {"layout": {"score": {"x_offset": 1}}}}}}
|
||||
r = self._r(config, "live")
|
||||
assert r.offset_value("score", "y_offset") == -2
|
||||
assert r.offset_value("score", "x_offset") == 1
|
||||
|
||||
def test_an_explicit_zero_is_an_override_not_an_absence(self):
|
||||
"""The reason None is the inherit sentinel: 0 has to mean something."""
|
||||
config = {"customization": {
|
||||
"layout": {"score": {"y_offset": -2}},
|
||||
"modes": {"live": {"layout": {"score": {"y_offset": 0}}}}}}
|
||||
assert self._r(config, "live").offset_value("score", "y_offset") == 0
|
||||
|
||||
def test_a_null_mode_offset_inherits(self):
|
||||
config = {"customization": {
|
||||
"layout": {"score": {"y_offset": -2}},
|
||||
"modes": {"live": {"layout": {"score": {"y_offset": None}}}}}}
|
||||
assert self._r(config, "live").offset_value("score", "y_offset") == -2
|
||||
|
||||
def test_offset_pair_is_mode_aware(self):
|
||||
config = {"customization": {
|
||||
"layout": {"score": {"x_offset": 1, "y_offset": 2}},
|
||||
"modes": {"live": {"layout": {"score": {"y_offset": 9}}}}}}
|
||||
assert self._r(config, "live").offset("score") == (1, 9)
|
||||
|
||||
def test_an_arbitrary_axis_is_mode_aware(self):
|
||||
"""The scoreboards use away_x_offset / home_x_offset."""
|
||||
config = {"customization": {
|
||||
"layout": {"records": {"away_x_offset": 3}},
|
||||
"modes": {"recent": {"layout": {"records": {"away_x_offset": 7}}}}}}
|
||||
assert self._r(config, "recent").offset_value(
|
||||
"records", "away_x_offset") == 7
|
||||
|
||||
@pytest.mark.parametrize("modes", [
|
||||
None, "nonsense", {"live": "nonsense"}, {"live": {"layout": 5}},
|
||||
{"live": {"layout": {"score": {"y_offset": "bad"}}}},
|
||||
])
|
||||
def test_garbage_modes_degrade_to_the_base(self, modes):
|
||||
config = {"customization": {"layout": {"score": {"y_offset": -2}},
|
||||
"modes": modes}}
|
||||
assert self._r(config, "live").offset_value("score", "y_offset") == -2
|
||||
|
||||
@pytest.mark.parametrize("modes", [
|
||||
None, "nonsense", {"live": "nonsense"},
|
||||
{"live": {"title_text": "nonsense"}},
|
||||
{"live": {"title_text": {"font_size": "huge", "text_color": "red"}}},
|
||||
])
|
||||
def test_garbage_mode_styles_degrade(self, modes, style_schema_path):
|
||||
config = {"customization": {"title_text": {"font_size": 12},
|
||||
"modes": modes}}
|
||||
st = ElementStyleResolver(
|
||||
config, defaults_from_schema_file(style_schema_path),
|
||||
mode="live").style("title_text", classic_size=8)
|
||||
assert st.font_size == 12
|
||||
|
||||
MODE_SCHEMA = {
|
||||
"type": "object",
|
||||
"additionalProperties": False,
|
||||
"properties": {
|
||||
"enabled": {"type": "boolean", "default": False},
|
||||
"customization": {
|
||||
"type": "object",
|
||||
"x-style-modes": ["live", "recent"],
|
||||
"x-style-elements": {
|
||||
"score_text": {
|
||||
"title": "Score",
|
||||
"font": {"default": "PressStart2P-Regular.ttf"},
|
||||
"size": {"default": 10, "min": 4, "max": 16},
|
||||
"color": {"default": [255, 255, 255]},
|
||||
"offsets": True,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class TestModeSchemaEmission:
|
||||
def _expanded(self):
|
||||
return expand_style_elements(MODE_SCHEMA)["properties"]["customization"]
|
||||
|
||||
def test_a_modes_block_is_emitted_per_declared_mode(self):
|
||||
assert sorted(self._expanded()["properties"]["modes"]["properties"]) == [
|
||||
"live", "recent"]
|
||||
|
||||
def test_mode_fields_are_nullable_and_default_to_null(self):
|
||||
"""Null is the inherit sentinel. A concrete default here would turn
|
||||
every mode into a copy of the base the moment the user pressed Save,
|
||||
because the save flow writes schema defaults into config wholesale."""
|
||||
live = self._expanded()["properties"]["modes"]["properties"]["live"]
|
||||
block = live["properties"]["score_text"]["properties"]
|
||||
for name, prop in block.items():
|
||||
assert prop["default"] is None, name
|
||||
assert "null" in prop["type"], name
|
||||
|
||||
def test_mode_offsets_are_nullable_too(self):
|
||||
live = self._expanded()["properties"]["modes"]["properties"]["live"]
|
||||
axes = live["properties"]["layout"]["properties"]["score_text"]["properties"]
|
||||
assert set(axes) == {"x_offset", "y_offset"}
|
||||
for prop in axes.values():
|
||||
assert prop["default"] is None
|
||||
assert "null" in prop["type"]
|
||||
|
||||
def test_the_base_block_keeps_its_real_defaults(self):
|
||||
block = self._expanded()["properties"]["score_text"]["properties"]
|
||||
assert block["font_size"]["default"] == 10
|
||||
assert block["font"]["default"] == "PressStart2P-Regular.ttf"
|
||||
|
||||
def test_the_font_field_asks_for_the_font_selector_widget(self):
|
||||
"""The widget already existed and was already allowlisted by the
|
||||
config form; without the hint the field was a bare text box the user
|
||||
had to type a filename into."""
|
||||
block = self._expanded()["properties"]["score_text"]["properties"]
|
||||
assert block["font"]["x-widget"] == "font-selector"
|
||||
|
||||
def test_no_declared_modes_emits_no_modes_block(self):
|
||||
schema = copy.deepcopy(MODE_SCHEMA)
|
||||
del schema["properties"]["customization"]["x-style-modes"]
|
||||
expanded = expand_style_elements(schema)["properties"]["customization"]
|
||||
assert "modes" not in expanded["properties"]
|
||||
|
||||
@pytest.mark.parametrize("modes", ["nonsense", [], [None], [""], 5, {}])
|
||||
def test_garbage_mode_declarations_do_not_break_expansion(self, modes):
|
||||
schema = copy.deepcopy(MODE_SCHEMA)
|
||||
schema["properties"]["customization"]["x-style-modes"] = modes
|
||||
expanded = expand_style_elements(schema)["properties"]["customization"]
|
||||
assert "score_text" in expanded["properties"]
|
||||
|
||||
def test_the_input_schema_is_not_mutated(self):
|
||||
expand_style_elements(MODE_SCHEMA)
|
||||
assert "properties" not in MODE_SCHEMA["properties"]["customization"]
|
||||
|
||||
|
||||
class TestModeSaveRoundTrip:
|
||||
"""The path a real save takes: schema -> defaults -> merge -> validate ->
|
||||
resolve. The risk being covered is that the save flow writes schema
|
||||
defaults into config.json wholesale, so a mode block has to survive that
|
||||
still meaning "inherit"."""
|
||||
|
||||
@pytest.fixture
|
||||
def plugin(self, tmp_path):
|
||||
from src.plugin_system.schema_manager import SchemaManager
|
||||
pdir = tmp_path / "plugins" / "demo"
|
||||
pdir.mkdir(parents=True)
|
||||
(pdir / "config_schema.json").write_text(json.dumps(MODE_SCHEMA),
|
||||
encoding="utf-8")
|
||||
sm = SchemaManager(plugins_dir=tmp_path / "plugins",
|
||||
project_root=tmp_path)
|
||||
schema = sm.load_schema("demo", use_cache=False)
|
||||
return sm, schema, sm.extract_defaults_from_schema(schema), pdir
|
||||
|
||||
def test_a_save_leaves_mode_blocks_meaning_inherit(self, plugin):
|
||||
sm, schema, defaults, pdir = plugin
|
||||
merged = sm.merge_with_defaults(
|
||||
{"enabled": True, "customization": {"score_text": {"font_size": 14}}},
|
||||
defaults)
|
||||
live = merged["customization"]["modes"]["live"]["score_text"]
|
||||
assert live == {"font": None, "font_size": None, "text_color": None}
|
||||
|
||||
ok, errors = sm.validate_config_against_schema(merged, schema)
|
||||
assert ok, errors
|
||||
|
||||
style = ElementStyleResolver(
|
||||
merged, defaults_from_schema_file(str(pdir / "config_schema.json")),
|
||||
mode="live").style("score_text",
|
||||
classic_font="PressStart2P-Regular.ttf",
|
||||
classic_size=10)
|
||||
assert style.font_size == 14, "the base must still reach the mode"
|
||||
|
||||
def test_a_real_mode_override_validates_and_wins(self, plugin):
|
||||
sm, schema, defaults, pdir = plugin
|
||||
merged = sm.merge_with_defaults({"customization": {
|
||||
"score_text": {"font_size": 14},
|
||||
"modes": {"live": {"score_text": {"font_size": 16},
|
||||
"layout": {"score_text": {"y_offset": -3}}}},
|
||||
}}, defaults)
|
||||
ok, errors = sm.validate_config_against_schema(merged, schema)
|
||||
assert ok, errors
|
||||
|
||||
schema_defaults = defaults_from_schema_file(
|
||||
str(pdir / "config_schema.json"))
|
||||
live = ElementStyleResolver(merged, schema_defaults, mode="live")
|
||||
recent = ElementStyleResolver(merged, schema_defaults, mode="recent")
|
||||
assert live.style("score_text", classic_size=10).font_size == 16
|
||||
assert live.offset("score_text") == (0, -3)
|
||||
assert recent.style("score_text", classic_size=10).font_size == 14
|
||||
assert recent.offset("score_text") == (0, 0)
|
||||
|
||||
def test_a_mode_size_outside_the_declared_range_is_rejected(self, plugin):
|
||||
"""min/max from the declaration must carry into the mode blocks."""
|
||||
sm, schema, defaults, _ = plugin
|
||||
merged = sm.merge_with_defaults(
|
||||
{"customization": {"modes": {"live": {"score_text": {
|
||||
"font_size": 99}}}}}, defaults)
|
||||
ok, _errors = sm.validate_config_against_schema(merged, schema)
|
||||
assert not ok
|
||||
|
||||
EXTRA_SCHEMA = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"customization": {
|
||||
"type": "object",
|
||||
"x-style-modes": ["live"],
|
||||
"x-style-elements": {
|
||||
"score_text": {
|
||||
"title": "Score",
|
||||
"font": {"default": "PressStart2P-Regular.ttf"},
|
||||
"size": {"default": 10},
|
||||
"color": {"default": [255, 255, 255]},
|
||||
"visible": True,
|
||||
"align": {"default": "center"},
|
||||
"offsets": True,
|
||||
},
|
||||
"home_logo": {
|
||||
"title": "Home Logo",
|
||||
"offsets": True,
|
||||
"visible": True,
|
||||
"scale": {"default": 1.0, "min": 0.25, "max": 4},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def extra_schema_path(tmp_path):
|
||||
path = tmp_path / "extra_schema.json"
|
||||
path.write_text(json.dumps(EXTRA_SCHEMA), encoding="utf-8")
|
||||
return str(path)
|
||||
|
||||
|
||||
class TestVisibleAlignScale:
|
||||
"""visible / align / scale all resolve to "change nothing" until asked.
|
||||
|
||||
That is the same invariant the font fields keep: a caller that honours
|
||||
these must still render an untouched config exactly as it did before
|
||||
they existed, so the neutral values are True / None / 1.0 rather than
|
||||
whatever the schema happens to declare.
|
||||
"""
|
||||
|
||||
def _style(self, config, mode=None, schema=None):
|
||||
defaults = defaults_from_schema_file(schema) if schema else {}
|
||||
return ElementStyleResolver(config, defaults, mode=mode).style(
|
||||
"score_text", classic_font="PressStart2P-Regular.ttf",
|
||||
classic_size=10, classic_color=(255, 255, 255))
|
||||
|
||||
def test_an_untouched_config_is_neutral(self, extra_schema_path):
|
||||
st = self._style({}, schema=extra_schema_path)
|
||||
assert (st.visible, st.align, st.scale) == (True, None, 1.0)
|
||||
|
||||
def test_a_schema_default_is_not_a_choice(self, extra_schema_path):
|
||||
"""align defaults to 'center' in the schema, and the save flow writes
|
||||
that into config -- which must not read as the user asking for it."""
|
||||
config = {"customization": {"score_text": {"align": "center",
|
||||
"visible": True}}}
|
||||
st = self._style(config, schema=extra_schema_path)
|
||||
assert st.align is None, "the plugin keeps its own alignment"
|
||||
assert st.visible is True
|
||||
|
||||
def test_hiding_an_element(self, extra_schema_path):
|
||||
config = {"customization": {"score_text": {"visible": False}}}
|
||||
assert self._style(config, schema=extra_schema_path).visible is False
|
||||
|
||||
def test_a_real_alignment_choice_comes_through(self, extra_schema_path):
|
||||
config = {"customization": {"score_text": {"align": "right"}}}
|
||||
assert self._style(config, schema=extra_schema_path).align == "right"
|
||||
|
||||
@pytest.mark.parametrize("written,expected", [
|
||||
("left", "left"), ("RIGHT", "right"), (" center ", "center"),
|
||||
("centre", "center"), ("middle", "center"),
|
||||
("sideways", None), ("", None), (5, None), (None, None),
|
||||
])
|
||||
def test_alignment_coercion(self, written, expected, extra_schema_path):
|
||||
config = {"customization": {"score_text": {"align": written}}}
|
||||
assert self._style(config, schema=extra_schema_path).align == expected
|
||||
|
||||
@pytest.mark.parametrize("written,expected", [
|
||||
(2, 2.0), (0.5, 0.5), ("1.5", 1.5),
|
||||
(0, 1.0), (-3, 1.0), # nonsense degrades, never inverts
|
||||
(999, 10.0), # clamped: the panel is 32px tall
|
||||
("huge", 1.0), (None, 1.0), (True, 1.0),
|
||||
])
|
||||
def test_scale_coercion(self, written, expected, extra_schema_path):
|
||||
config = {"customization": {"layout": {"score_text": {"scale": written}}}}
|
||||
assert self._style(config, schema=extra_schema_path).scale == expected
|
||||
|
||||
def test_scale_reads_from_the_layout_block(self, extra_schema_path):
|
||||
"""scale is geometry, so it sits with the offsets -- a logo has a
|
||||
scale and no font."""
|
||||
config = {"customization": {"layout": {"score_text": {"scale": 2}}}}
|
||||
assert self._style(config, schema=extra_schema_path).scale == 2.0
|
||||
|
||||
@pytest.mark.parametrize("field,value,attr,expected", [
|
||||
("visible", False, "visible", False),
|
||||
("align", "right", "align", "right"),
|
||||
])
|
||||
def test_a_mode_overrides_them(self, field, value, attr, expected,
|
||||
extra_schema_path):
|
||||
config = {"customization": {"modes": {
|
||||
"live": {"score_text": {field: value}}}}}
|
||||
st = self._style(config, mode="live", schema=extra_schema_path)
|
||||
assert getattr(st, attr) == expected
|
||||
|
||||
def test_a_mode_overrides_scale(self, extra_schema_path):
|
||||
config = {"customization": {
|
||||
"layout": {"score_text": {"scale": 2}},
|
||||
"modes": {"live": {"layout": {"score_text": {"scale": 3}}}}}}
|
||||
assert self._style(config, mode="live",
|
||||
schema=extra_schema_path).scale == 3.0
|
||||
|
||||
def test_an_unset_mode_field_inherits_the_base(self, extra_schema_path):
|
||||
config = {"customization": {
|
||||
"score_text": {"visible": False},
|
||||
"modes": {"live": {"score_text": {"align": "right"}}}}}
|
||||
st = self._style(config, mode="live", schema=extra_schema_path)
|
||||
assert st.visible is False and st.align == "right"
|
||||
|
||||
|
||||
class TestExtraFieldSchema:
|
||||
def _props(self):
|
||||
return expand_style_elements(
|
||||
EXTRA_SCHEMA)["properties"]["customization"]["properties"]
|
||||
|
||||
def test_only_declared_subfields_are_emitted(self):
|
||||
"""home_logo declares no font, so it gets no font control."""
|
||||
assert list(self._props()["home_logo"]["properties"]) == ["visible"]
|
||||
|
||||
def test_a_text_element_gets_the_text_fields(self):
|
||||
assert list(self._props()["score_text"]["properties"]) == [
|
||||
"font", "font_size", "text_color", "visible", "align"]
|
||||
|
||||
def test_scale_is_emitted_with_the_offsets(self):
|
||||
layout = self._props()["layout"]["properties"]
|
||||
assert list(layout["home_logo"]["properties"]) == [
|
||||
"x_offset", "y_offset", "scale"]
|
||||
assert list(layout["score_text"]["properties"]) == [
|
||||
"x_offset", "y_offset"], "scale is opt-in"
|
||||
|
||||
def test_declared_scale_bounds_are_kept(self):
|
||||
scale = self._props()["layout"]["properties"]["home_logo"]["properties"]["scale"]
|
||||
assert (scale["minimum"], scale["maximum"]) == (0.25, 4)
|
||||
|
||||
def test_align_offers_only_valid_choices(self):
|
||||
align = self._props()["score_text"]["properties"]["align"]
|
||||
assert align["enum"] == ["left", "center", "right"]
|
||||
|
||||
def test_the_mode_copies_are_nullable(self):
|
||||
live = self._props()["modes"]["properties"]["live"]["properties"]
|
||||
assert live["score_text"]["properties"]["visible"]["default"] is None
|
||||
assert "null" in live["score_text"]["properties"]["visible"]["type"]
|
||||
scale = live["layout"]["properties"]["home_logo"]["properties"]["scale"]
|
||||
assert scale["default"] is None and "null" in scale["type"]
|
||||
|
||||
class TestAliasKeys:
|
||||
"""Two naming conventions collided as the scoreboards grew.
|
||||
|
||||
Counted across the published schemas: the style block names elements
|
||||
with a _text suffix (score_text, status_text), while the layout block
|
||||
mostly uses the bare noun (score, date, odds) -- except status_text,
|
||||
which kept the suffix in seven plugins and lost it in two. records vs
|
||||
record splits seven to two the same way.
|
||||
|
||||
Renaming config keys to fix that would orphan whatever offsets users had
|
||||
already dialled in, so lookups try the alternatives instead.
|
||||
"""
|
||||
|
||||
@pytest.mark.parametrize("key,expected", [
|
||||
("score_text", ("score_text", "score")),
|
||||
("status", ("status", "status_text")),
|
||||
("status_text", ("status_text", "status")),
|
||||
("records", ("records", "record")),
|
||||
("record", ("record", "records")),
|
||||
("rank_text", ("rank_text", "ranking", "rank")),
|
||||
("team_name", ("team_name", "team")),
|
||||
])
|
||||
def test_the_spellings_tried(self, key, expected):
|
||||
assert alias_keys(key) == expected
|
||||
|
||||
def test_the_exact_name_is_always_first(self):
|
||||
for key in ("score_text", "status", "records", "home_logo"):
|
||||
assert alias_keys(key)[0] == key
|
||||
|
||||
def test_an_explicit_entry_replaces_the_suffix_rule(self):
|
||||
"""'records' must not also generate the meaningless 'records_text'."""
|
||||
assert "records_text" not in alias_keys("records")
|
||||
|
||||
@pytest.mark.parametrize("key", ["", None, 5, [], {}])
|
||||
def test_nonsense_keys_yield_nothing(self, key):
|
||||
assert alias_keys(key) == ()
|
||||
|
||||
|
||||
class TestAliasedLookup:
|
||||
def _r(self, config, mode=None):
|
||||
return ElementStyleResolver(config, {}, mode=mode)
|
||||
|
||||
def test_a_compact_plugin_finds_offsets_saved_under_the_bare_noun(self):
|
||||
"""The migration case: a scoreboard moving to the compact form asks
|
||||
for score_text offsets, and its users wrote layout.score."""
|
||||
config = {"customization": {"layout": {"score": {"y_offset": -3}}}}
|
||||
assert self._r(config).offset("score_text") == (0, -3)
|
||||
|
||||
def test_status_and_status_text_find_each_other(self):
|
||||
written_long = {"customization": {"layout": {"status_text": {"x_offset": 2}}}}
|
||||
written_short = {"customization": {"layout": {"status": {"x_offset": 4}}}}
|
||||
assert self._r(written_long).offset("status") == (2, 0)
|
||||
assert self._r(written_short).offset("status_text") == (4, 0)
|
||||
|
||||
def test_record_and_records_find_each_other(self):
|
||||
config = {"customization": {"layout": {"records": {"away_x_offset": 5}}}}
|
||||
assert self._r(config).offset_value("record", "away_x_offset") == 5
|
||||
|
||||
def test_an_exact_match_beats_an_alias(self):
|
||||
"""Nothing changes for a config that already uses the right name."""
|
||||
config = {"customization": {"layout": {
|
||||
"score": {"y_offset": 1}, "score_text": {"y_offset": 9}}}}
|
||||
assert self._r(config).offset("score_text") == (0, 9)
|
||||
assert self._r(config).offset("score") == (0, 1)
|
||||
|
||||
def test_style_blocks_alias_too(self, style_schema_path):
|
||||
config = {"customization": {"title": {"font_size": 13}}}
|
||||
st = ElementStyleResolver(
|
||||
config, defaults_from_schema_file(style_schema_path)
|
||||
).style("title_text", classic_size=8)
|
||||
assert st.font_size == 13
|
||||
|
||||
def test_a_mode_block_aliases_too(self):
|
||||
config = {"customization": {
|
||||
"layout": {"score": {"y_offset": -3}},
|
||||
"modes": {"live": {"layout": {"score": {"y_offset": 7}}}}}}
|
||||
assert self._r(config, "live").offset("score_text") == (0, 7)
|
||||
|
||||
def test_an_unrelated_element_is_unaffected(self):
|
||||
config = {"customization": {"layout": {"home_logo": {"x_offset": 3}}}}
|
||||
r = self._r(config)
|
||||
assert r.offset("home_logo") == (3, 0)
|
||||
assert r.offset("away_logo") == (0, 0)
|
||||
|
||||
def test_scale_is_found_through_an_alias(self, extra_schema_path):
|
||||
config = {"customization": {"layout": {"score": {"scale": 2}}}}
|
||||
st = ElementStyleResolver(
|
||||
config, defaults_from_schema_file(extra_schema_path)
|
||||
).style("score_text")
|
||||
assert st.scale == 2.0
|
||||
|
||||
HANDWRITTEN = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"customization": {
|
||||
"type": "object",
|
||||
"title": "Display Customization",
|
||||
"properties": {
|
||||
"score_text": {
|
||||
"type": "object",
|
||||
"title": "Game Score",
|
||||
"properties": {
|
||||
"font": {
|
||||
"type": "string",
|
||||
"enum": ["PressStart2P-Regular.ttf", "4x6-font.ttf",
|
||||
"5by7.regular.ttf", "5x7.bdf", "4x6.bdf"],
|
||||
"default": "PressStart2P-Regular.ttf",
|
||||
},
|
||||
"font_size": {"type": "integer", "minimum": 4,
|
||||
"maximum": 16, "default": 10},
|
||||
"text_color": {"type": "array", "minItems": 3,
|
||||
"maxItems": 3, "default": [255, 255, 255]},
|
||||
},
|
||||
},
|
||||
"favorite_result_colors": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enabled": {"type": "boolean", "default": False},
|
||||
"win_color": {"type": "array", "default": [0, 255, 0]},
|
||||
},
|
||||
},
|
||||
# baseball's 'count' shape: one field this system knows,
|
||||
# next to geometry that means nothing to it.
|
||||
"count": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"text_color": {"type": "array", "default": [0, 255, 0]},
|
||||
"y_offset": {"type": "integer", "default": 2},
|
||||
},
|
||||
},
|
||||
"layout": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"score": {"type": "object", "properties": {
|
||||
"x_offset": {"type": "integer", "default": 0},
|
||||
"y_offset": {"type": "integer", "default": 0}}},
|
||||
"home_logo": {"type": "object", "properties": {
|
||||
"x_offset": {"type": "integer", "default": 0}}},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class TestHandWrittenAdoption:
|
||||
"""Nineteen plugins spell their style elements out longhand -- football's
|
||||
block is 701 lines for seven elements -- and predate this system. Core
|
||||
recognises that shape so they pick up the editor and the real font picker
|
||||
on a core update rather than on a plugin release.
|
||||
"""
|
||||
|
||||
def _customization(self, schema=None):
|
||||
return expand_style_elements(
|
||||
schema or HANDWRITTEN)["properties"]["customization"]
|
||||
|
||||
def test_the_block_gets_the_composite_editor(self):
|
||||
assert self._customization()["x-widget"] == "style-editor"
|
||||
|
||||
def test_the_declared_order_is_stated_explicitly(self):
|
||||
"""Flask's JSON provider sorts keys, so without this the elements
|
||||
reach the browser alphabetised."""
|
||||
order = self._customization()["x-propertyOrder"]
|
||||
assert order[0] == "score_text"
|
||||
|
||||
def test_a_block_that_is_not_styling_is_left_alone(self):
|
||||
"""favorite_result_colors, baseball's bases/outs/player_card and the
|
||||
stocks blocks all carry fields this system knows nothing about."""
|
||||
block = self._customization()["properties"]["favorite_result_colors"]
|
||||
assert "x-style-managed" not in block
|
||||
assert block["properties"]["win_color"]["default"] == [0, 255, 0]
|
||||
|
||||
def test_a_block_that_merely_shares_a_field_is_left_alone(self):
|
||||
"""The reason detection requires *every* field to be one this system
|
||||
understands. baseball's 'count' carries a text_color beside a
|
||||
y_offset that means nothing here.
|
||||
|
||||
Asserted on the element list rather than on the block itself: an
|
||||
over-eager rule leaves a fontless block looking untouched, and only
|
||||
shows up as an extra row in the editor and an extra per-mode
|
||||
override group.
|
||||
"""
|
||||
schema = copy.deepcopy(HANDWRITTEN)
|
||||
schema["properties"]["customization"]["x-style-modes"] = ["live"]
|
||||
live = expand_style_elements(schema)["properties"]["customization"][
|
||||
"properties"]["modes"]["properties"]["live"]["properties"]
|
||||
assert "score_text" in live
|
||||
assert "count" not in live, (
|
||||
"'count' is not a style element -- it shares one field and "
|
||||
"carries geometry this system does not understand")
|
||||
assert "favorite_result_colors" not in live
|
||||
|
||||
def test_the_hardcoded_font_list_is_replaced_by_the_picker(self):
|
||||
"""The reason an uploaded font could never appear in one of these."""
|
||||
font = self._customization()["properties"]["score_text"]["properties"]["font"]
|
||||
assert "enum" not in font
|
||||
assert font["x-widget"] == "font-selector"
|
||||
|
||||
def test_the_picker_inherits_the_declared_size_ceiling(self):
|
||||
"""A bitmap font ignores font_size and renders at its own baked-in
|
||||
size, so a field capped at 16 must not offer a 27px face."""
|
||||
font = self._customization()["properties"]["score_text"]["properties"]["font"]
|
||||
assert font["x-options"]["maxFixedSize"] == 16
|
||||
|
||||
def test_the_users_existing_font_choice_stays_valid(self):
|
||||
font = self._customization()["properties"]["score_text"]["properties"]["font"]
|
||||
assert font["default"] == "PressStart2P-Regular.ttf"
|
||||
assert font["type"] == "string"
|
||||
|
||||
def test_the_input_schema_is_not_mutated(self):
|
||||
expand_style_elements(HANDWRITTEN)
|
||||
original = HANDWRITTEN["properties"]["customization"]
|
||||
assert "x-widget" not in original
|
||||
assert "enum" in original["properties"]["score_text"]["properties"]["font"]
|
||||
|
||||
def test_no_style_blocks_means_no_expansion(self):
|
||||
schema = {"properties": {"customization": {"type": "object",
|
||||
"properties": {"favorite_result_colors": {"type": "object",
|
||||
"properties": {"win_color": {"type": "array"}}}}}}}
|
||||
assert expand_style_elements(schema) is schema
|
||||
|
||||
|
||||
class TestAdoptedModes:
|
||||
"""Per-mode overrides stay opt-in: core cannot invent a plugin's list of
|
||||
display modes. Declaring x-style-modes is the one line that unlocks them
|
||||
for a hand-written block."""
|
||||
|
||||
def _live(self):
|
||||
schema = copy.deepcopy(HANDWRITTEN)
|
||||
schema["properties"]["customization"]["x-style-modes"] = ["live", "recent"]
|
||||
return expand_style_elements(schema)["properties"]["customization"][
|
||||
"properties"]["modes"]["properties"]["live"]["properties"]
|
||||
|
||||
def test_modes_are_not_invented(self):
|
||||
assert "modes" not in self._customization_props()
|
||||
|
||||
def _customization_props(self):
|
||||
return expand_style_elements(HANDWRITTEN)["properties"]["customization"]["properties"]
|
||||
|
||||
def test_declaring_modes_generates_them(self):
|
||||
assert "score_text" in self._live()
|
||||
|
||||
def test_mode_fields_are_nullable(self):
|
||||
size = self._live()["score_text"]["properties"]["font_size"]
|
||||
assert size["default"] is None and "null" in size["type"]
|
||||
|
||||
def test_every_positionable_element_gets_per_mode_offsets(self):
|
||||
"""The two namespaces do not line up in a hand-written schema: this
|
||||
one styles 'score_text' but positions 'score', and positions a logo
|
||||
that has no style block at all. Keying the layout off the style
|
||||
elements would have left both without a per-mode offset."""
|
||||
layout = self._live()["layout"]["properties"]
|
||||
assert set(layout) == {"score", "home_logo"}
|
||||
|
||||
def test_the_mode_font_picker_keeps_the_ceiling(self):
|
||||
font = self._live()["score_text"]["properties"]["font"]
|
||||
assert font["x-options"]["maxFixedSize"] == 16
|
||||
assert "null" in font["type"]
|
||||
|
||||
@@ -193,3 +193,122 @@ class TestTables:
|
||||
|
||||
def test_month_and_weekday_tables_are_complete(self):
|
||||
assert len(C.MONTH_ABBR) == 12 and len(C.WEEKDAY_ABBR) == 7
|
||||
|
||||
|
||||
class TestUnshareElementFonts:
|
||||
"""Re-instantiated faces must match the ones they replace.
|
||||
|
||||
``unshare_element_fonts`` rebuilds a duplicate face purely so two elements
|
||||
can be told apart by ``id()``. That is only safe while the rebuilt face
|
||||
lays text out identically -- and PIL's two layout engines disagree on
|
||||
fractional advances, which is why ``src.common.font_layout`` exists and
|
||||
pins one. Rebuilding through bare ``ImageFont.truetype`` took the default
|
||||
engine instead, so a re-instantiated face could measure differently from
|
||||
the shared face it replaced.
|
||||
"""
|
||||
|
||||
def _face(self, size=10):
|
||||
from src.common.font_layout import load_truetype
|
||||
path = os.path.join("assets", "fonts", "PressStart2P-Regular.ttf")
|
||||
return load_truetype(path, size)
|
||||
|
||||
def test_rebuilt_face_goes_through_the_pinned_loader(self, log, monkeypatch):
|
||||
"""Asserted on the loader, not on the resulting engine value.
|
||||
|
||||
PIL only selects Raqm when it is installed; where it is not, a bare
|
||||
``ImageFont.truetype`` returns BASIC too, so comparing engine values
|
||||
passes on those machines whether or not the pin is honoured -- this
|
||||
test did exactly that before it was rewritten. Spying on the pinned
|
||||
loader fails on every machine when the pin is bypassed, which is the
|
||||
point: the cross-machine mismatch font_layout exists to prevent
|
||||
cannot be reproduced on a Raqm-less runner.
|
||||
"""
|
||||
import src.common.font_layout as fl
|
||||
|
||||
# Build the face BEFORE patching: _face() loads through the same
|
||||
# pinned loader, so patching first would let the fixture's own call
|
||||
# satisfy the assertion and the test would pass either way.
|
||||
shared = self._face()
|
||||
|
||||
calls = []
|
||||
real = fl.load_truetype
|
||||
|
||||
def spy(font, size, **kwargs):
|
||||
calls.append((font, size))
|
||||
return real(font, size, **kwargs)
|
||||
|
||||
monkeypatch.setattr(fl, "load_truetype", spy)
|
||||
fonts = {"score": shared, "time": shared}
|
||||
C.unshare_element_fonts(log, fonts)
|
||||
assert fonts["time"] is not shared, "the duplicate should have been rebuilt"
|
||||
assert calls, "the rebuild must go through the pinned loader"
|
||||
|
||||
def test_rebuilt_face_keeps_the_shared_faces_layout_engine(self, log):
|
||||
shared = self._face()
|
||||
fonts = {"score": shared, "time": shared}
|
||||
C.unshare_element_fonts(log, fonts)
|
||||
assert fonts["time"].layout_engine == shared.layout_engine
|
||||
|
||||
def test_rebuilt_face_measures_identically(self, log):
|
||||
shared = self._face()
|
||||
fonts = {"score": shared, "time": shared}
|
||||
C.unshare_element_fonts(log, fonts)
|
||||
text = "88-88"
|
||||
assert (fonts["time"].getlength(text)
|
||||
== shared.getlength(text)), "metrics must not shift"
|
||||
|
||||
def test_distinct_faces_are_left_alone(self, log):
|
||||
a, b = self._face(10), self._face(8)
|
||||
fonts = {"score": a, "time": b}
|
||||
C.unshare_element_fonts(log, fonts)
|
||||
assert fonts["score"] is a and fonts["time"] is b
|
||||
|
||||
class TestSharedElementReaders:
|
||||
"""The scoreboards' colour and offset reads now go through one
|
||||
implementation.
|
||||
|
||||
There were two copies of the colour read and three of the offset read.
|
||||
They had already drifted: the scroll-card renderer carries a comment
|
||||
noting it used to ignore offsets its own schema advertised. Sharing one
|
||||
reader is what lets a fix (or an alias, or a per-mode override) reach
|
||||
the full-screen scorebug and the scroll card together.
|
||||
"""
|
||||
|
||||
CONFIG = {
|
||||
"customization": {
|
||||
"score_text": {"text_color": [255, 200, 0]},
|
||||
"status": {"text_color": "#00ff00"},
|
||||
"layout": {"score": {"y_offset": -3}},
|
||||
"modes": {
|
||||
"live": {"score_text": {"text_color": [255, 0, 0]},
|
||||
"layout": {"score": {"y_offset": 7}}},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
def test_colour_reads_the_configured_value(self, log):
|
||||
assert C.element_color(self.CONFIG, "score_text") == (255, 200, 0)
|
||||
|
||||
def test_hex_colours_are_still_accepted(self, log):
|
||||
"""These readers have always taken '#RRGGBB' as well as [r, g, b],
|
||||
so the shared one had to learn it rather than the callers losing it."""
|
||||
assert C.element_color(self.CONFIG, "status") == (0, 255, 0)
|
||||
|
||||
def test_colour_resolves_through_an_alias(self, log):
|
||||
"""The style block says status_text where this config says status."""
|
||||
assert C.element_color(self.CONFIG, "status_text") == (0, 255, 0)
|
||||
|
||||
def test_a_mode_overrides_the_colour(self, log):
|
||||
assert C.element_color(self.CONFIG, "score_text",
|
||||
mode="live") == (255, 0, 0)
|
||||
|
||||
def test_an_unset_element_gets_the_default(self, log):
|
||||
assert C.element_color(self.CONFIG, "nothing", (1, 2, 3)) == (1, 2, 3)
|
||||
|
||||
@pytest.mark.parametrize("config", [
|
||||
None, {}, "nonsense", {"customization": "nonsense"},
|
||||
{"customization": {"score_text": "nonsense"}},
|
||||
{"customization": {"score_text": {"text_color": "not a colour"}}},
|
||||
])
|
||||
def test_a_hostile_config_gives_the_default(self, config, log):
|
||||
assert C.element_color(config, "score_text", (9, 9, 9)) == (9, 9, 9)
|
||||
|
||||
@@ -256,3 +256,48 @@ class TestContract:
|
||||
h._draw_upcoming_center(_draw(), {})
|
||||
h._draw_upcoming_game_status(_draw(), {})
|
||||
h.set_rankings_cache({})
|
||||
|
||||
|
||||
class TestLayoutOffsetsAreShared:
|
||||
"""The scroll/Vegas card reads offsets through the same function the
|
||||
full-screen scorebug does.
|
||||
|
||||
This renderer already carries a comment about having ignored offsets its
|
||||
own schema advertised -- it was the third copy of this read. Sharing one
|
||||
means the alias handling and per-mode overrides land here too, without
|
||||
this file knowing about either.
|
||||
"""
|
||||
|
||||
CONFIG = {"customization": {
|
||||
"layout": {"score": {"y_offset": -3}},
|
||||
"modes": {"live": {"layout": {"score": {"y_offset": 7}}}},
|
||||
}}
|
||||
|
||||
def _host(self, config, mode=None):
|
||||
attrs = {"config": config}
|
||||
if mode:
|
||||
attrs["SKIN_MODE"] = mode
|
||||
return type("Card", (SportsGameRendererMixin,), attrs)()
|
||||
|
||||
def test_it_reads_the_configured_offset(self):
|
||||
assert self._host(self.CONFIG)._layout_offset("score", "y_offset") == -3
|
||||
|
||||
def test_it_resolves_through_an_alias(self):
|
||||
"""A caller asking for score_text finds the layout.score the user
|
||||
configured -- the two namespaces disagree by convention."""
|
||||
assert self._host(self.CONFIG)._layout_offset(
|
||||
"score_text", "y_offset") == -3
|
||||
|
||||
def test_skin_mode_selects_the_per_mode_offset(self):
|
||||
assert self._host(self.CONFIG, "live")._layout_offset(
|
||||
"score", "y_offset") == 7
|
||||
|
||||
def test_a_host_without_a_skin_mode_gets_the_base(self):
|
||||
assert self._host(self.CONFIG)._layout_offset("score", "y_offset") == -3
|
||||
|
||||
@pytest.mark.parametrize("written", [
|
||||
float("inf"), float("nan"), True, "bad", None, [], {},
|
||||
])
|
||||
def test_nonsense_degrades_to_the_default(self, written):
|
||||
config = {"customization": {"layout": {"score": {"y_offset": written}}}}
|
||||
assert self._host(config)._layout_offset("score", "y_offset", 4) == 4
|
||||
|
||||
@@ -374,3 +374,91 @@ class TestPluginDirIsToldNotDeduced:
|
||||
assert host._schema_font_size("score") == 16, (
|
||||
"without the schema this is None, which is what made a configured "
|
||||
"size look user-chosen and skipped the grid snap")
|
||||
class TestUnshareKeepsTheLayoutEngine:
|
||||
"""``_unshare_element_fonts`` rebuilds a face; the rebuild must be pinned.
|
||||
|
||||
This body did move verbatim from the plugins, but it is the one that
|
||||
rebuilds a font, and it rebuilt through bare ``ImageFont.truetype`` --
|
||||
taking PIL's default layout engine rather than the one
|
||||
``src.common.font_layout`` pins. Raqm and Basic disagree on fractional
|
||||
advances, so a re-instantiated face could measure differently from the
|
||||
shared face it replaced, on any machine where Raqm is installed. That is
|
||||
invisible on a Raqm-less runner, which is why this asserts on the loader
|
||||
rather than on the resulting engine value.
|
||||
"""
|
||||
|
||||
def _host(self):
|
||||
import logging
|
||||
return type("H", (SportsCoreSharedMixin,), {
|
||||
"logger": logging.getLogger("test_sports_shared")})()
|
||||
|
||||
def _face(self, size=10):
|
||||
from src.common.font_layout import load_truetype
|
||||
return load_truetype(
|
||||
os.path.join("assets", "fonts", "PressStart2P-Regular.ttf"), size)
|
||||
|
||||
def test_rebuild_goes_through_the_pinned_loader(self, monkeypatch):
|
||||
import src.common.font_layout as fl
|
||||
|
||||
# Built before patching: _face() uses the same loader, so patching
|
||||
# first would let the fixture's own call satisfy the assertion.
|
||||
shared = self._face()
|
||||
|
||||
calls = []
|
||||
real = fl.load_truetype
|
||||
|
||||
def spy(font, size, **kwargs):
|
||||
calls.append((font, size))
|
||||
return real(font, size, **kwargs)
|
||||
|
||||
monkeypatch.setattr(fl, "load_truetype", spy)
|
||||
fonts = {"score": shared, "time": shared}
|
||||
self._host()._unshare_element_fonts(fonts)
|
||||
assert fonts["time"] is not shared, "the duplicate should have been rebuilt"
|
||||
assert calls, "the rebuild must go through the pinned loader"
|
||||
|
||||
def test_rebuilt_face_measures_like_the_one_it_replaced(self):
|
||||
shared = self._face()
|
||||
fonts = {"score": shared, "time": shared}
|
||||
self._host()._unshare_element_fonts(fonts)
|
||||
assert fonts["time"].getlength("88-88") == shared.getlength("88-88")
|
||||
|
||||
|
||||
class TestPromotedLayoutOffset:
|
||||
"""_get_layout_offset moved here so every scoreboard reads offsets the
|
||||
way the scroll card does.
|
||||
|
||||
Each plugin still carries its own copy in its bundled sports.py, which
|
||||
wins by MRO. That is the migration property: adopting this is a
|
||||
deletion in the plugin, and until that deletion nothing changes.
|
||||
"""
|
||||
|
||||
CONFIG = {"customization": {
|
||||
"layout": {"score": {"y_offset": -3}},
|
||||
"modes": {"recent": {"layout": {"score": {"y_offset": 9}}}},
|
||||
}}
|
||||
|
||||
def _host(self, **attrs):
|
||||
return type("H", (SportsCoreSharedMixin,),
|
||||
dict({"config": self.CONFIG}, **attrs))()
|
||||
|
||||
def test_it_reads_the_configured_offset(self):
|
||||
assert self._host()._get_layout_offset("score", "y_offset") == -3
|
||||
|
||||
def test_it_resolves_through_an_alias(self):
|
||||
"""What a plugin gains by deleting its own copy: the style block
|
||||
says score_text where the layout block says score."""
|
||||
assert self._host()._get_layout_offset("score_text", "y_offset") == -3
|
||||
|
||||
def test_skin_mode_selects_the_per_mode_offset(self):
|
||||
assert self._host(SKIN_MODE="recent")._get_layout_offset(
|
||||
"score", "y_offset") == 9
|
||||
|
||||
def test_a_plugins_own_copy_still_wins(self):
|
||||
"""Until a plugin deletes its copy, this changes nothing for it."""
|
||||
host = self._host(
|
||||
_get_layout_offset=lambda self, element, axis, default=0: 99)
|
||||
assert host._get_layout_offset("score", "y_offset") == 99
|
||||
|
||||
def test_an_unset_offset_is_the_default(self):
|
||||
assert self._host()._get_layout_offset("nothing", "y_offset", 5) == 5
|
||||
|
||||
+15
-22
@@ -814,28 +814,21 @@ class TestFontsAPI:
|
||||
data = json.loads(response.data)
|
||||
assert 'tokens' in data.get('data', {}) or 'data' in data
|
||||
|
||||
def test_get_fonts_overrides(self, client):
|
||||
"""Test getting font overrides."""
|
||||
response = client.get('/api/v3/fonts/overrides')
|
||||
|
||||
assert response.status_code == 200
|
||||
data = json.loads(response.data)
|
||||
assert 'overrides' in data.get('data', {}) or 'data' in data
|
||||
|
||||
def test_save_fonts_overrides(self, client):
|
||||
"""Test saving font overrides."""
|
||||
request_data = {
|
||||
'weather': 'small',
|
||||
'clock': 'regular'
|
||||
}
|
||||
|
||||
response = client.post(
|
||||
'/api/v3/fonts/overrides',
|
||||
data=json.dumps(request_data),
|
||||
content_type='application/json'
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
def test_the_font_override_endpoints_are_gone(self, client):
|
||||
"""They reported success without doing anything: GET returned a
|
||||
hardcoded {}, POST and DELETE saved and deleted nothing. The panel
|
||||
they served offered eleven element keys -- nfl.live.score,
|
||||
clock.time -- that no plugin has ever read, so wiring them to the
|
||||
real FontManager methods would still have changed nothing on the
|
||||
panel. Per-element font choice lives in each plugin's own config
|
||||
editor now, against the elements that plugin actually has."""
|
||||
# 405, not 404: the path still matches DELETE /fonts/<font_family>,
|
||||
# which now reads "overrides" as a font name. Nothing is routed to a
|
||||
# handler for GET or POST, which is what matters here.
|
||||
assert client.get('/api/v3/fonts/overrides').status_code == 405
|
||||
assert client.post('/api/v3/fonts/overrides',
|
||||
data=json.dumps({}),
|
||||
content_type='application/json').status_code == 405
|
||||
|
||||
|
||||
class TestAPIErrorHandling:
|
||||
|
||||
@@ -25,6 +25,7 @@ from web_interface.blueprints.api_v3 import ( # noqa: E402
|
||||
_parse_form_value,
|
||||
_get_schema_property,
|
||||
_set_nested_value,
|
||||
_SKIP_FIELD,
|
||||
)
|
||||
|
||||
|
||||
@@ -221,12 +222,21 @@ class TestSetNestedValue:
|
||||
_set_nested_value(config, "fifa.world.enabled", True)
|
||||
assert config == {"fifa.world": {"enabled": True}}
|
||||
|
||||
def test_none_does_not_overwrite_existing(self):
|
||||
def test_none_overwrites_existing(self):
|
||||
# Regression: None is a real value here (the per-mode "inherit the
|
||||
# base" override for a nullable field, or a blank indexed color
|
||||
# channel) and must replace whatever was already stored. Only the
|
||||
# _SKIP_FIELD sentinel means "leave it alone" -- see the next test.
|
||||
config = {"a": 1}
|
||||
_set_nested_value(config, "a", None)
|
||||
assert config == {"a": 1}
|
||||
assert config == {"a": None}
|
||||
|
||||
def test_none_sets_missing_key(self):
|
||||
config = {}
|
||||
_set_nested_value(config, "a", None)
|
||||
assert config == {"a": None}
|
||||
|
||||
def test_skip_field_does_not_overwrite_existing(self):
|
||||
config = {"a": 1}
|
||||
_set_nested_value(config, "a", _SKIP_FIELD)
|
||||
assert config == {"a": 1}
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
"""The config form must render the same schema the save route validates.
|
||||
|
||||
The form used to read config_schema.json with a raw json.load while
|
||||
api_v3.save_plugin_config went through SchemaManager. That is not a stylistic
|
||||
difference: SchemaManager applies expand_style_elements, which turns a compact
|
||||
``customization.x-style-elements`` declaration into the per-element blocks the
|
||||
form knows how to render. Without it the customization object has an
|
||||
x-style-elements key and no ``properties``, so the template's object branch
|
||||
matched nothing and the whole section rendered as empty space -- while saving
|
||||
still validated against the expanded shape.
|
||||
|
||||
of-the-day ships the compact form, so this was live.
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
|
||||
PROJECT_ROOT = Path(__file__).parent.parent.parent
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
COMPACT_SCHEMA = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enabled": {"type": "boolean", "default": False},
|
||||
"customization": {
|
||||
"type": "object",
|
||||
"title": "Display Customization",
|
||||
"x-style-modes": ["live", "recent"],
|
||||
"x-style-elements": {
|
||||
"title_text": {
|
||||
"title": "Title",
|
||||
"font": {"default": "PressStart2P-Regular.ttf"},
|
||||
"size": {"default": 8, "min": 4, "max": 16},
|
||||
"color": {"default": [255, 255, 255]},
|
||||
"offsets": True,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
MANUAL_SCHEMA = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enabled": {"type": "boolean", "default": False},
|
||||
"customization": {
|
||||
"type": "object",
|
||||
"title": "Display Customization",
|
||||
"properties": {
|
||||
"title_text": {
|
||||
"type": "object",
|
||||
"title": "Title",
|
||||
"properties": {
|
||||
"font_size": {"type": "integer", "default": 8},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def render(tmp_path):
|
||||
"""Render a plugin's config partial, with or without a SchemaManager."""
|
||||
from web_interface.blueprints import pages_v3 as pv
|
||||
from src.plugin_system.schema_manager import SchemaManager
|
||||
|
||||
orig_pm = getattr(pv.pages_v3, "plugin_manager", None)
|
||||
orig_cm = getattr(pv.pages_v3, "config_manager", None)
|
||||
orig_sm = getattr(pv.pages_v3, "schema_manager", None)
|
||||
|
||||
def _render(schema, with_schema_manager=True, plugin_id="demo"):
|
||||
pdir = tmp_path / "plugins" / plugin_id
|
||||
pdir.mkdir(parents=True, exist_ok=True)
|
||||
(pdir / "config_schema.json").write_text(json.dumps(schema),
|
||||
encoding="utf-8")
|
||||
(pdir / "manifest.json").write_text(
|
||||
json.dumps({"id": plugin_id, "name": plugin_id}), encoding="utf-8")
|
||||
|
||||
pm = MagicMock()
|
||||
pm.plugins_dir = str(tmp_path / "plugins")
|
||||
pm.get_plugin_info.return_value = {"id": plugin_id, "name": plugin_id}
|
||||
pm.get_plugin.return_value = None
|
||||
pv.pages_v3.plugin_manager = pm
|
||||
|
||||
cm = MagicMock()
|
||||
cm.load_config.return_value = {plugin_id: {"enabled": True}}
|
||||
pv.pages_v3.config_manager = cm
|
||||
|
||||
pv.pages_v3.schema_manager = (
|
||||
SchemaManager(plugins_dir=tmp_path / "plugins",
|
||||
project_root=tmp_path)
|
||||
if with_schema_manager else None)
|
||||
|
||||
base = PROJECT_ROOT / "web_interface"
|
||||
app = Flask(__name__,
|
||||
template_folder=str(base / "templates"),
|
||||
static_folder=str(base / "static"))
|
||||
app.config["TESTING"] = True
|
||||
app.register_blueprint(pv.pages_v3, url_prefix="")
|
||||
resp = app.test_client().get(f"/partials/plugin-config/{plugin_id}")
|
||||
assert resp.status_code == 200, resp.status_code
|
||||
return resp.get_data(as_text=True)
|
||||
|
||||
try:
|
||||
yield _render
|
||||
finally:
|
||||
pv.pages_v3.plugin_manager = orig_pm
|
||||
pv.pages_v3.config_manager = orig_cm
|
||||
pv.pages_v3.schema_manager = orig_sm
|
||||
|
||||
|
||||
class TestCompactDeclarationRenders:
|
||||
def test_the_element_fields_appear(self, render):
|
||||
"""This is the regression: the section used to render empty."""
|
||||
body = render(COMPACT_SCHEMA)
|
||||
assert 'name="customization.title_text.font_size"' in body
|
||||
# The font field is widget-rendered, so its <select name=...> is
|
||||
# created client-side; what the server emits is the container and
|
||||
# the key handed to the widget.
|
||||
assert "name: 'customization.title_text.font'" in body
|
||||
|
||||
def test_the_colour_field_renders_as_rgb_inputs(self, render):
|
||||
body = render(COMPACT_SCHEMA)
|
||||
for channel in range(3):
|
||||
assert f'name="customization.title_text.text_color.{channel}"' in body
|
||||
|
||||
def test_the_font_field_uses_the_font_selector_widget(self, render):
|
||||
body = render(COMPACT_SCHEMA)
|
||||
assert "LEDMatrixWidgets.get('font-selector')" in body
|
||||
|
||||
def test_layout_offsets_appear(self, render):
|
||||
body = render(COMPACT_SCHEMA)
|
||||
assert 'name="customization.layout.title_text.x_offset"' in body
|
||||
assert 'name="customization.layout.title_text.y_offset"' in body
|
||||
|
||||
def test_declared_modes_appear(self, render):
|
||||
body = render(COMPACT_SCHEMA)
|
||||
assert 'name="customization.modes.live.title_text.font_size"' in body
|
||||
assert 'name="customization.modes.recent.title_text.font_size"' in body
|
||||
|
||||
def test_mode_layout_offsets_appear(self, render):
|
||||
body = render(COMPACT_SCHEMA)
|
||||
assert ('name="customization.modes.live.layout.title_text.y_offset"'
|
||||
in body)
|
||||
|
||||
|
||||
class TestWithoutASchemaManager:
|
||||
"""Several callers register this blueprint without a schema_manager; the
|
||||
raw read stays as their fallback."""
|
||||
|
||||
def test_a_manual_block_still_renders(self, render):
|
||||
body = render(MANUAL_SCHEMA, with_schema_manager=False)
|
||||
assert 'name="customization.title_text.font_size"' in body
|
||||
|
||||
def test_a_compact_declaration_renders_nothing_without_expansion(self, render):
|
||||
"""Documents precisely what the fix buys: the fallback path cannot
|
||||
expand, so this is what every plugin using the compact form saw."""
|
||||
body = render(COMPACT_SCHEMA, with_schema_manager=False)
|
||||
assert 'name="customization.title_text.font"' not in body
|
||||
|
||||
|
||||
class TestRenderAndSaveAgree:
|
||||
def test_the_form_posts_names_the_save_path_recognises(self, render):
|
||||
"""The rendered field names must resolve against the same expanded
|
||||
schema the save route looks them up in."""
|
||||
from web_interface.blueprints.api_v3 import _get_schema_property
|
||||
from src.element_style import expand_style_elements
|
||||
|
||||
render(COMPACT_SCHEMA) # ensure it renders at all
|
||||
schema = expand_style_elements(COMPACT_SCHEMA)
|
||||
for path in ("customization.title_text.font",
|
||||
"customization.title_text.font_size",
|
||||
"customization.layout.title_text.x_offset",
|
||||
"customization.modes.live.title_text.font_size"):
|
||||
assert _get_schema_property(schema, path) is not None, path
|
||||
@@ -0,0 +1,261 @@
|
||||
"""The server half of the plugin-supplied widget feature.
|
||||
|
||||
``LEDMatrixWidgets.loadPluginWidget`` (static/v3/js/widgets/plugin-loader.js)
|
||||
has always fetched ``/static/plugin-widgets/<plugin>/<widget>.js``, and
|
||||
docs/widget-guide.md has always documented that path, but nothing served it --
|
||||
so a plugin could declare a widget, ship the file, and still never load it.
|
||||
soccer-scoreboard has shipped exactly that since August.
|
||||
|
||||
The load-bearing property here is that the manifest is the allowlist. A plugin
|
||||
directory is attacker-influenced in the sense that matters -- plugins are
|
||||
user-installed, and the store installs them -- so "serve files from the plugin
|
||||
directory" would publish everything a plugin ships. Only a widget the manifest
|
||||
declares is reachable, and only from that plugin's widgets/ directory.
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
|
||||
PROJECT_ROOT = Path(__file__).parent.parent.parent
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
WIDGET_BODY = "(function(){ window.LEDMatrixWidgets.register('custom-leagues', {}); })();\n"
|
||||
|
||||
_UNSET = object()
|
||||
|
||||
|
||||
def _make_plugin(plugins_dir, plugin_id="soccer-scoreboard", widgets=None,
|
||||
files=None):
|
||||
"""Write a plugin directory with a manifest and a widgets/ folder."""
|
||||
d = plugins_dir / plugin_id
|
||||
(d / "widgets").mkdir(parents=True)
|
||||
manifest = {"id": plugin_id, "name": plugin_id, "version": "1.0.0"}
|
||||
if widgets is not None:
|
||||
manifest["widgets"] = widgets
|
||||
(d / "manifest.json").write_text(json.dumps(manifest), encoding="utf-8")
|
||||
for name, body in (files or {}).items():
|
||||
(d / "widgets" / name).write_text(body, encoding="utf-8")
|
||||
return d
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def make_client(tmp_path):
|
||||
"""Build a test client whose plugin manager points at a temp plugins dir.
|
||||
|
||||
pages_v3 is a module-level Blueprint singleton shared across the test
|
||||
process, so the original plugin_manager is restored on teardown.
|
||||
"""
|
||||
from web_interface.blueprints import pages_v3 as pv
|
||||
|
||||
original_pm = getattr(pv.pages_v3, "plugin_manager", None)
|
||||
|
||||
def _build(plugins_dir=None, plugin_manager=_UNSET):
|
||||
base = PROJECT_ROOT / "web_interface"
|
||||
app = Flask(__name__,
|
||||
template_folder=str(base / "templates"),
|
||||
static_folder=str(base / "static"))
|
||||
app.config["TESTING"] = True
|
||||
|
||||
if plugin_manager is _UNSET:
|
||||
plugin_manager = MagicMock()
|
||||
plugin_manager.plugins_dir = str(plugins_dir or tmp_path)
|
||||
pv.pages_v3.plugin_manager = plugin_manager
|
||||
|
||||
app.register_blueprint(pv.pages_v3, url_prefix="")
|
||||
return app.test_client()
|
||||
|
||||
try:
|
||||
yield _build
|
||||
finally:
|
||||
pv.pages_v3.plugin_manager = original_pm
|
||||
|
||||
|
||||
URL = "/static/plugin-widgets/{}/{}.js"
|
||||
|
||||
|
||||
class TestDeclaredWidgetIsServed:
|
||||
def test_a_declared_widget_is_served(self, tmp_path, make_client):
|
||||
_make_plugin(tmp_path,
|
||||
widgets=[{"name": "custom-leagues",
|
||||
"script": "custom-leagues.js"}],
|
||||
files={"custom-leagues.js": WIDGET_BODY})
|
||||
r = make_client().get(URL.format("soccer-scoreboard", "custom-leagues"))
|
||||
assert r.status_code == 200
|
||||
assert r.get_data(as_text=True) == WIDGET_BODY
|
||||
|
||||
def test_it_is_served_as_javascript(self, tmp_path, make_client):
|
||||
"""The loader uses dynamic import(); a wrong MIME type is refused."""
|
||||
_make_plugin(tmp_path,
|
||||
widgets=[{"name": "custom-leagues",
|
||||
"script": "custom-leagues.js"}],
|
||||
files={"custom-leagues.js": WIDGET_BODY})
|
||||
r = make_client().get(URL.format("soccer-scoreboard", "custom-leagues"))
|
||||
assert "javascript" in r.headers["Content-Type"]
|
||||
|
||||
def test_script_defaults_to_the_widget_name(self, tmp_path, make_client):
|
||||
_make_plugin(tmp_path, widgets=[{"name": "custom-leagues"}],
|
||||
files={"custom-leagues.js": WIDGET_BODY})
|
||||
r = make_client().get(URL.format("soccer-scoreboard", "custom-leagues"))
|
||||
assert r.status_code == 200
|
||||
|
||||
def test_the_ledmatrix_prefix_fallback_resolves(self, tmp_path, make_client):
|
||||
"""PluginManager resolves 'music' to 'ledmatrix-music'; so must this."""
|
||||
_make_plugin(tmp_path, plugin_id="ledmatrix-music",
|
||||
widgets=[{"name": "deck", "script": "deck.js"}],
|
||||
files={"deck.js": WIDGET_BODY})
|
||||
r = make_client().get(URL.format("music", "deck"))
|
||||
assert r.status_code == 200
|
||||
|
||||
|
||||
class TestTheManifestIsTheAllowlist:
|
||||
def test_an_undeclared_file_in_widgets_is_not_served(self, tmp_path, make_client):
|
||||
"""The whole point: shipping a file does not publish it."""
|
||||
_make_plugin(tmp_path, widgets=[],
|
||||
files={"secrets.js": "const KEY='hunter2';"})
|
||||
r = make_client().get(URL.format("soccer-scoreboard", "secrets"))
|
||||
assert r.status_code == 404
|
||||
assert "hunter2" not in r.get_data(as_text=True)
|
||||
|
||||
def test_a_manifest_with_no_widgets_key_serves_nothing(self, tmp_path, make_client):
|
||||
_make_plugin(tmp_path, files={"anything.js": WIDGET_BODY})
|
||||
r = make_client().get(URL.format("soccer-scoreboard", "anything"))
|
||||
assert r.status_code == 404
|
||||
|
||||
def test_a_declared_widget_whose_file_is_missing_is_404(self, tmp_path, make_client):
|
||||
_make_plugin(tmp_path, widgets=[{"name": "ghost", "script": "ghost.js"}])
|
||||
r = make_client().get(URL.format("soccer-scoreboard", "ghost"))
|
||||
assert r.status_code == 404
|
||||
|
||||
def test_a_malformed_manifest_serves_nothing(self, tmp_path, make_client):
|
||||
d = tmp_path / "broken"
|
||||
(d / "widgets").mkdir(parents=True)
|
||||
(d / "manifest.json").write_text("{not json", encoding="utf-8")
|
||||
(d / "widgets" / "w.js").write_text(WIDGET_BODY, encoding="utf-8")
|
||||
assert make_client().get(URL.format("broken", "w")).status_code == 404
|
||||
|
||||
|
||||
class TestPathTraversal:
|
||||
@pytest.mark.parametrize("plugin_id", ["../etc", "..%2f..", "a/b", "a\\b", ""])
|
||||
def test_a_hostile_plugin_id_never_reaches_the_filesystem(
|
||||
self, plugin_id, tmp_path, make_client):
|
||||
r = make_client().get(URL.format(plugin_id, "custom-leagues"))
|
||||
assert r.status_code in (400, 404), r.status_code
|
||||
|
||||
@pytest.mark.parametrize("widget", ["../manifest", "..%2fsecret", "a/b"])
|
||||
def test_a_hostile_widget_name_never_reaches_the_filesystem(
|
||||
self, widget, tmp_path, make_client):
|
||||
_make_plugin(tmp_path, widgets=[{"name": "custom-leagues"}],
|
||||
files={"custom-leagues.js": WIDGET_BODY})
|
||||
r = make_client().get(URL.format("soccer-scoreboard", widget))
|
||||
assert r.status_code in (400, 404), r.status_code
|
||||
|
||||
def test_a_manifest_cannot_escape_the_widgets_directory(self, tmp_path, make_client):
|
||||
"""A hostile manifest is the traversal vector the URL allowlist can't
|
||||
cover: the script name comes from the plugin, not the request."""
|
||||
(tmp_path / "loot.js").write_text("const KEY='hunter2';", encoding="utf-8")
|
||||
_make_plugin(tmp_path,
|
||||
widgets=[{"name": "evil", "script": "../../loot.js"}])
|
||||
r = make_client().get(URL.format("soccer-scoreboard", "evil"))
|
||||
assert r.status_code == 404
|
||||
assert "hunter2" not in r.get_data(as_text=True)
|
||||
|
||||
|
||||
class TestDegradation:
|
||||
def test_an_unknown_plugin_is_404(self, tmp_path, make_client):
|
||||
assert make_client().get(URL.format("nope", "w")).status_code == 404
|
||||
|
||||
def test_no_plugin_manager_is_503(self, make_client):
|
||||
r = make_client(plugin_manager=None).get(URL.format("any", "w"))
|
||||
assert r.status_code == 503
|
||||
|
||||
|
||||
def _schema_with_widget(widget_name):
|
||||
return {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enabled": {"type": "boolean", "default": False},
|
||||
"leagues": {"type": "string", "default": "eng.1",
|
||||
"x-widget": widget_name},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def config_form(tmp_path):
|
||||
"""Render a plugin's config partial with a temp plugin on disk."""
|
||||
from web_interface.blueprints import pages_v3 as pv
|
||||
|
||||
orig_pm = getattr(pv.pages_v3, "plugin_manager", None)
|
||||
orig_cm = getattr(pv.pages_v3, "config_manager", None)
|
||||
|
||||
def _render(plugin_id="soccer-scoreboard", schema=None, widgets=None,
|
||||
files=None):
|
||||
d = _make_plugin(tmp_path, plugin_id, widgets=widgets, files=files)
|
||||
(d / "config_schema.json").write_text(
|
||||
json.dumps(schema or {"type": "object", "properties": {}}),
|
||||
encoding="utf-8")
|
||||
|
||||
pm = MagicMock()
|
||||
pm.plugins_dir = str(tmp_path)
|
||||
pm.get_plugin_info.return_value = {"id": plugin_id, "name": plugin_id}
|
||||
pm.get_plugin.return_value = None
|
||||
pv.pages_v3.plugin_manager = pm
|
||||
|
||||
cm = MagicMock()
|
||||
cm.load_config.return_value = {plugin_id: {"enabled": True}}
|
||||
pv.pages_v3.config_manager = cm
|
||||
|
||||
base = PROJECT_ROOT / "web_interface"
|
||||
app = Flask(__name__,
|
||||
template_folder=str(base / "templates"),
|
||||
static_folder=str(base / "static"))
|
||||
app.config["TESTING"] = True
|
||||
app.register_blueprint(pv.pages_v3, url_prefix="")
|
||||
return app.test_client().get(f"/partials/plugin-config/{plugin_id}")
|
||||
|
||||
try:
|
||||
yield _render
|
||||
finally:
|
||||
pv.pages_v3.plugin_manager = orig_pm
|
||||
pv.pages_v3.config_manager = orig_cm
|
||||
|
||||
|
||||
class TestTheFormRequestsPluginWidgets:
|
||||
"""Without this the feature is still dead: the route can serve a widget,
|
||||
but nothing ever asks for one. The server-side form only knows a hardcoded
|
||||
list of core widget names, so a plugin's own x-widget fell through to a
|
||||
plain text input and was never fetched."""
|
||||
|
||||
def test_an_unknown_widget_name_triggers_a_plugin_load(self, config_form):
|
||||
r = config_form(schema=_schema_with_widget("custom-leagues"),
|
||||
widgets=[{"name": "custom-leagues"}],
|
||||
files={"custom-leagues.js": WIDGET_BODY})
|
||||
assert r.status_code == 200
|
||||
body = r.get_data(as_text=True)
|
||||
assert "ensureWidget" in body
|
||||
assert '"custom-leagues"' in body
|
||||
|
||||
def test_the_text_input_remains_as_the_fallback(self, config_form):
|
||||
"""A widget that fails to load must not cost the user their value."""
|
||||
r = config_form(schema=_schema_with_widget("custom-leagues"),
|
||||
widgets=[{"name": "custom-leagues"}],
|
||||
files={"custom-leagues.js": WIDGET_BODY})
|
||||
body = r.get_data(as_text=True)
|
||||
assert 'name="leagues"' in body
|
||||
assert 'value="eng.1"' in body
|
||||
|
||||
def test_a_plain_string_field_asks_for_no_widget(self, config_form):
|
||||
r = config_form(schema={"type": "object", "properties": {
|
||||
"leagues": {"type": "string", "default": "eng.1"}}})
|
||||
assert "ensureWidget" not in r.get_data(as_text=True)
|
||||
|
||||
def test_a_core_widget_does_not_take_the_plugin_path(self, config_form):
|
||||
r = config_form(schema=_schema_with_widget("font-selector"))
|
||||
body = r.get_data(as_text=True)
|
||||
assert "ensureWidget" not in body
|
||||
assert "LEDMatrixWidgets.get('font-selector')" in body
|
||||
@@ -0,0 +1,212 @@
|
||||
"""visible / align / scale, from the form the widget renders to the style the
|
||||
display resolves.
|
||||
|
||||
These three are the difference between "restyle the text" and "lay the card
|
||||
out", and each has a way of going quietly wrong in a form post:
|
||||
|
||||
- ``visible`` is a boolean, and an unchecked checkbox posts nothing at all,
|
||||
so the widget carries the value in a hidden field. The save path has to
|
||||
turn those strings into real booleans, or config.json grows "false"
|
||||
strings that are truthy everywhere they are read.
|
||||
- ``align`` is an enum, so an invalid value must not reach the renderer.
|
||||
- ``scale`` is a float living in the layout block rather than the element
|
||||
block, because a logo has a scale and no font.
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
|
||||
PROJECT_ROOT = Path(__file__).parent.parent.parent
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
SCHEMA = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enabled": {"type": "boolean", "default": True},
|
||||
"customization": {
|
||||
"type": "object",
|
||||
"x-style-modes": ["live"],
|
||||
"x-style-elements": {
|
||||
"score_text": {
|
||||
"title": "Score",
|
||||
"font": {"default": "PressStart2P-Regular.ttf"},
|
||||
"size": {"default": 10, "min": 4, "max": 16},
|
||||
"color": {"default": [255, 255, 255]},
|
||||
"visible": True,
|
||||
"align": {"default": "center"},
|
||||
"offsets": True,
|
||||
},
|
||||
"home_logo": {
|
||||
"title": "Home Logo",
|
||||
"offsets": True,
|
||||
"visible": True,
|
||||
"scale": {"default": 1.0, "min": 0.25, "max": 4},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class _SaveOk:
|
||||
status = type("S", (), {"value": "success"})()
|
||||
message = None
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def post(tmp_path):
|
||||
from src.plugin_system.schema_manager import SchemaManager
|
||||
from web_interface.blueprints import api_v3 as a
|
||||
|
||||
originals = {k: getattr(a.api_v3, k, None)
|
||||
for k in ("config_manager", "schema_manager", "plugin_manager")}
|
||||
|
||||
pdir = tmp_path / "plugins" / "demo"
|
||||
pdir.mkdir(parents=True)
|
||||
(pdir / "config_schema.json").write_text(json.dumps(SCHEMA), encoding="utf-8")
|
||||
|
||||
store = {"demo": {"enabled": True}}
|
||||
cm = MagicMock()
|
||||
cm.load_config.side_effect = lambda: json.loads(json.dumps(store))
|
||||
cm.get_config_path.return_value = str(tmp_path / "config.json")
|
||||
|
||||
def _save(cfg, **_kw):
|
||||
store.clear()
|
||||
store.update(cfg)
|
||||
return _SaveOk()
|
||||
|
||||
cm.save_config_atomic.side_effect = _save
|
||||
|
||||
a.api_v3.config_manager = cm
|
||||
a.api_v3.schema_manager = SchemaManager(plugins_dir=tmp_path / "plugins",
|
||||
project_root=tmp_path)
|
||||
pm = MagicMock()
|
||||
pm.plugins = {}
|
||||
pm.get_plugin.return_value = None
|
||||
a.api_v3.plugin_manager = pm
|
||||
|
||||
app = Flask(__name__)
|
||||
app.config["TESTING"] = True
|
||||
app.register_blueprint(a.api_v3, url_prefix="/api/v3")
|
||||
client = app.test_client()
|
||||
|
||||
def _post(extra):
|
||||
form = {
|
||||
"enabled": "true",
|
||||
"customization.score_text.font": "PressStart2P-Regular.ttf",
|
||||
"customization.score_text.font_size": "10",
|
||||
"customization.score_text.text_color.0": "255",
|
||||
"customization.score_text.text_color.1": "255",
|
||||
"customization.score_text.text_color.2": "255",
|
||||
"customization.score_text.visible": "true",
|
||||
"customization.score_text.align": "center",
|
||||
"customization.layout.score_text.x_offset": "0",
|
||||
"customization.layout.score_text.y_offset": "0",
|
||||
"customization.home_logo.visible": "true",
|
||||
"customization.layout.home_logo.x_offset": "0",
|
||||
"customization.layout.home_logo.y_offset": "0",
|
||||
"customization.layout.home_logo.scale": "1",
|
||||
}
|
||||
form.update(extra)
|
||||
resp = client.post("/api/v3/plugins/config?plugin_id=demo", data=form)
|
||||
return resp, store.get("demo", {})
|
||||
|
||||
def _style(cfg, element="score_text", mode=None):
|
||||
from src.element_style import (ElementStyleResolver,
|
||||
defaults_from_schema_file)
|
||||
defaults = defaults_from_schema_file(str(pdir / "config_schema.json"))
|
||||
return ElementStyleResolver(cfg, defaults, mode=mode).style(
|
||||
element, classic_font="PressStart2P-Regular.ttf", classic_size=10,
|
||||
classic_color=(255, 255, 255))
|
||||
|
||||
try:
|
||||
yield _post, _style
|
||||
finally:
|
||||
for k, v in originals.items():
|
||||
setattr(a.api_v3, k, v)
|
||||
|
||||
|
||||
class TestVisible:
|
||||
def test_a_checked_box_stores_a_real_boolean(self, post):
|
||||
_post, _ = post
|
||||
resp, cfg = _post({})
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
stored = cfg["customization"]["score_text"]["visible"]
|
||||
assert stored is True, f"stored {stored!r}, not a bool"
|
||||
|
||||
def test_hiding_an_element_survives_the_round_trip(self, post):
|
||||
_post, _style = post
|
||||
resp, cfg = _post({"customization.score_text.visible": "false"})
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
assert cfg["customization"]["score_text"]["visible"] is False
|
||||
assert _style(cfg).visible is False
|
||||
|
||||
def test_the_default_is_not_read_as_a_choice(self, post):
|
||||
"""The save flow writes the schema default in either way."""
|
||||
_post, _style = post
|
||||
_resp, cfg = _post({})
|
||||
assert _style(cfg).visible is True
|
||||
|
||||
|
||||
class TestAlign:
|
||||
def test_a_choice_reaches_the_display(self, post):
|
||||
_post, _style = post
|
||||
resp, cfg = _post({"customization.score_text.align": "right"})
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
assert _style(cfg).align == "right"
|
||||
|
||||
def test_the_schema_default_resolves_to_no_preference(self, post):
|
||||
"""'center' is what the schema declares, so the plugin keeps doing
|
||||
whatever it already did rather than being told to centre."""
|
||||
_post, _style = post
|
||||
_resp, cfg = _post({})
|
||||
assert _style(cfg).align is None
|
||||
|
||||
def test_an_invalid_alignment_is_rejected_on_save(self, post):
|
||||
_post, _ = post
|
||||
resp, _cfg = _post({"customization.score_text.align": "sideways"})
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
class TestScale:
|
||||
def test_a_logo_scale_saves_into_the_layout_block(self, post):
|
||||
_post, _style = post
|
||||
resp, cfg = _post({"customization.layout.home_logo.scale": "2.5"})
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
assert cfg["customization"]["layout"]["home_logo"]["scale"] == 2.5
|
||||
assert _style(cfg, "home_logo").scale == 2.5
|
||||
|
||||
def test_a_scale_outside_the_declared_range_is_rejected(self, post):
|
||||
_post, _ = post
|
||||
resp, _cfg = _post({"customization.layout.home_logo.scale": "99"})
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_an_element_without_a_declared_scale_stays_neutral(self, post):
|
||||
_post, _style = post
|
||||
_resp, cfg = _post({})
|
||||
assert _style(cfg, "score_text").scale == 1.0
|
||||
|
||||
|
||||
class TestPerMode:
|
||||
def test_a_mode_can_hide_what_the_base_shows(self, post):
|
||||
_post, _style = post
|
||||
resp, cfg = _post({"customization.modes.live.score_text.visible": "false",
|
||||
"customization.modes.live.score_text.align": "",
|
||||
"customization.modes.live.score_text.font": "",
|
||||
"customization.modes.live.score_text.font_size": ""})
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
assert _style(cfg).visible is True
|
||||
assert _style(cfg, mode="live").visible is False
|
||||
|
||||
def test_a_blank_mode_boolean_means_inherit(self, post):
|
||||
_post, _style = post
|
||||
resp, cfg = _post({"customization.score_text.visible": "false",
|
||||
"customization.modes.live.score_text.visible": ""})
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
assert cfg["customization"]["modes"]["live"]["score_text"]["visible"] is None
|
||||
assert _style(cfg, mode="live").visible is False, "inherits the base"
|
||||
@@ -0,0 +1,222 @@
|
||||
"""The style editor's form data must survive the real save route.
|
||||
|
||||
The widget posts ordinary dotted field names so the existing pipeline needs no
|
||||
new parsing -- but per-mode override fields are typed ``["integer", "null"]``
|
||||
and ``["array", "null"]``, and two places in that pipeline compared the
|
||||
declared type to a bare string:
|
||||
|
||||
- the indexed-array recombiner (``text_color.0/.1/.2`` -> one list) skipped
|
||||
anything whose type was not exactly ``'array'``, so a per-mode colour was
|
||||
never reassembled;
|
||||
- ``_parse_form_value_with_schema`` did the same, and turned a blank nullable
|
||||
field into ``[]`` rather than ``None`` -- which then failed the ``minItems``
|
||||
the colour array declares.
|
||||
|
||||
Both would have surfaced as "saving a scoreboard's per-mode colour fails
|
||||
validation", with nothing in the widget to suggest why. This posts what the
|
||||
widget really emits and asserts on what lands in config, then on what the
|
||||
resolver makes of it.
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
|
||||
PROJECT_ROOT = Path(__file__).parent.parent.parent
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
SCHEMA = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enabled": {"type": "boolean", "default": True},
|
||||
"customization": {
|
||||
"type": "object",
|
||||
"x-style-modes": ["live", "recent"],
|
||||
"x-style-elements": {
|
||||
"score_text": {
|
||||
"title": "Score",
|
||||
"font": {"default": "PressStart2P-Regular.ttf"},
|
||||
"size": {"default": 10, "min": 4, "max": 16},
|
||||
"color": {"default": [255, 255, 255]},
|
||||
"offsets": True,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class _SaveOk:
|
||||
status = type("S", (), {"value": "success"})()
|
||||
message = None
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def post(tmp_path):
|
||||
"""POST form data to the real save route; yields the stored config."""
|
||||
from src.plugin_system.schema_manager import SchemaManager
|
||||
from web_interface.blueprints import api_v3 as a
|
||||
|
||||
originals = {k: getattr(a.api_v3, k, None)
|
||||
for k in ("config_manager", "schema_manager", "plugin_manager")}
|
||||
|
||||
pdir = tmp_path / "plugins" / "demo"
|
||||
pdir.mkdir(parents=True)
|
||||
(pdir / "config_schema.json").write_text(json.dumps(SCHEMA), encoding="utf-8")
|
||||
|
||||
store = {"demo": {"enabled": True}}
|
||||
|
||||
cm = MagicMock()
|
||||
cm.load_config.side_effect = lambda: json.loads(json.dumps(store))
|
||||
cm.get_config_path.return_value = str(tmp_path / "config.json")
|
||||
|
||||
def _save(cfg, **_kw):
|
||||
store.clear()
|
||||
store.update(cfg)
|
||||
return _SaveOk()
|
||||
|
||||
cm.save_config_atomic.side_effect = _save
|
||||
|
||||
a.api_v3.config_manager = cm
|
||||
a.api_v3.schema_manager = SchemaManager(plugins_dir=tmp_path / "plugins",
|
||||
project_root=tmp_path)
|
||||
pm = MagicMock()
|
||||
pm.plugins = {}
|
||||
pm.get_plugin.return_value = None
|
||||
a.api_v3.plugin_manager = pm
|
||||
|
||||
app = Flask(__name__)
|
||||
app.config["TESTING"] = True
|
||||
app.register_blueprint(a.api_v3, url_prefix="/api/v3")
|
||||
client = app.test_client()
|
||||
|
||||
def _post(form):
|
||||
resp = client.post("/api/v3/plugins/config?plugin_id=demo", data=form)
|
||||
return resp, store.get("demo", {})
|
||||
|
||||
try:
|
||||
yield _post, pdir
|
||||
finally:
|
||||
for k, v in originals.items():
|
||||
setattr(a.api_v3, k, v)
|
||||
|
||||
|
||||
BASE_FORM = {
|
||||
"enabled": "true",
|
||||
"customization.score_text.font": "PressStart2P-Regular.ttf",
|
||||
"customization.score_text.font_size": "12",
|
||||
"customization.score_text.text_color.0": "255",
|
||||
"customization.score_text.text_color.1": "200",
|
||||
"customization.score_text.text_color.2": "0",
|
||||
"customization.layout.score_text.x_offset": "0",
|
||||
"customization.layout.score_text.y_offset": "0",
|
||||
}
|
||||
|
||||
# A mode the user left alone: every field blank. Blank colours are simply
|
||||
# absent, because the widget disables those inputs rather than posting "".
|
||||
BLANK_MODE = {
|
||||
"customization.modes.recent.score_text.font": "",
|
||||
"customization.modes.recent.score_text.font_size": "",
|
||||
"customization.modes.recent.layout.score_text.x_offset": "",
|
||||
"customization.modes.recent.layout.score_text.y_offset": "",
|
||||
}
|
||||
|
||||
|
||||
class TestSaveRoundTrip:
|
||||
def test_the_base_element_saves(self, post):
|
||||
_post, _ = post
|
||||
resp, cfg = _post(dict(BASE_FORM))
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
assert cfg["customization"]["score_text"] == {
|
||||
"font": "PressStart2P-Regular.ttf",
|
||||
"font_size": 12,
|
||||
"text_color": [255, 200, 0],
|
||||
}
|
||||
|
||||
def test_a_blank_mode_saves_as_null_not_zero(self, post):
|
||||
"""Null is the inherit sentinel; 0 or [] would pin the mode."""
|
||||
_post, _ = post
|
||||
resp, cfg = _post(dict(BASE_FORM, **BLANK_MODE))
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
recent = cfg["customization"]["modes"]["recent"]
|
||||
assert recent["score_text"] == {"font": None, "font_size": None,
|
||||
"text_color": None}
|
||||
assert recent["layout"]["score_text"] == {"x_offset": None,
|
||||
"y_offset": None}
|
||||
|
||||
def test_a_partly_set_mode_keeps_the_rest_inheriting(self, post):
|
||||
_post, _ = post
|
||||
resp, cfg = _post(dict(
|
||||
BASE_FORM,
|
||||
**{"customization.modes.live.score_text.font": "",
|
||||
"customization.modes.live.score_text.font_size": "16",
|
||||
"customization.modes.live.layout.score_text.x_offset": "",
|
||||
"customization.modes.live.layout.score_text.y_offset": "-3"}))
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
live = cfg["customization"]["modes"]["live"]
|
||||
assert live["score_text"]["font_size"] == 16
|
||||
assert live["score_text"]["font"] is None
|
||||
assert live["score_text"]["text_color"] is None
|
||||
assert live["layout"]["score_text"] == {"x_offset": None,
|
||||
"y_offset": -3}
|
||||
|
||||
def test_a_per_mode_colour_is_reassembled_into_a_list(self, post):
|
||||
"""The recombiner used to skip this: its type is ["array", "null"],
|
||||
not "array", so the three indexed inputs were never joined."""
|
||||
_post, _ = post
|
||||
resp, cfg = _post(dict(
|
||||
BASE_FORM,
|
||||
**{"customization.modes.live.score_text.text_color.0": "0",
|
||||
"customization.modes.live.score_text.text_color.1": "255",
|
||||
"customization.modes.live.score_text.text_color.2": "0"}))
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
assert (cfg["customization"]["modes"]["live"]["score_text"]["text_color"]
|
||||
== [0, 255, 0])
|
||||
|
||||
def test_all_blank_colour_channels_mean_inherit(self, post):
|
||||
"""A hand-written form (or an older widget) can still post three
|
||||
empty strings; they must not become [] and fail minItems."""
|
||||
_post, _ = post
|
||||
resp, cfg = _post(dict(
|
||||
BASE_FORM,
|
||||
**{"customization.modes.live.score_text.text_color.0": "",
|
||||
"customization.modes.live.score_text.text_color.1": "",
|
||||
"customization.modes.live.score_text.text_color.2": ""}))
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
assert (cfg["customization"]["modes"]["live"]["score_text"]["text_color"]
|
||||
is None)
|
||||
|
||||
|
||||
class TestWhatTheDisplayThenRenders:
|
||||
"""The point of the round trip: what the resolver makes of what was saved."""
|
||||
|
||||
def _styles(self, cfg, pdir):
|
||||
from src.element_style import (ElementStyleResolver,
|
||||
defaults_from_schema_file)
|
||||
defaults = defaults_from_schema_file(str(pdir / "config_schema.json"))
|
||||
out = {}
|
||||
for mode in (None, "live", "recent"):
|
||||
r = ElementStyleResolver(cfg, defaults, mode=mode)
|
||||
style = r.style("score_text",
|
||||
classic_font="PressStart2P-Regular.ttf",
|
||||
classic_size=10, classic_color=(255, 255, 255))
|
||||
out[mode] = (style.font_size, style.color, r.offset("score_text"))
|
||||
return out
|
||||
|
||||
def test_a_mode_override_reaches_the_display_and_the_rest_inherits(self, post):
|
||||
_post, pdir = post
|
||||
_resp, cfg = _post(dict(
|
||||
BASE_FORM, **BLANK_MODE,
|
||||
**{"customization.modes.live.score_text.font": "",
|
||||
"customization.modes.live.score_text.font_size": "16",
|
||||
"customization.modes.live.layout.score_text.x_offset": "",
|
||||
"customization.modes.live.layout.score_text.y_offset": "-3"}))
|
||||
styles = self._styles(cfg, pdir)
|
||||
assert styles[None] == (12, (255, 200, 0), (0, 0))
|
||||
# live overrides size and y, and inherits the colour it never set
|
||||
assert styles["live"] == (16, (255, 200, 0), (0, -3))
|
||||
assert styles["recent"] == (12, (255, 200, 0), (0, 0))
|
||||
@@ -168,6 +168,7 @@ pages_v3.config_manager = config_manager
|
||||
pages_v3.plugin_manager = plugin_manager
|
||||
pages_v3.plugin_store_manager = plugin_store_manager
|
||||
pages_v3.saved_repositories_manager = saved_repositories_manager
|
||||
pages_v3.schema_manager = schema_manager
|
||||
|
||||
api_v3.config_manager = config_manager
|
||||
api_v3.plugin_manager = plugin_manager
|
||||
|
||||
@@ -808,6 +808,31 @@ def _is_field_required(key_path, schema):
|
||||
return field_name in required
|
||||
# Sentinel object to indicate a field should be skipped (not set in config)
|
||||
_SKIP_FIELD = object()
|
||||
|
||||
|
||||
def _schema_type_is(prop, wanted):
|
||||
"""Whether a schema property is of ``wanted`` type.
|
||||
|
||||
JSON Schema allows a union (``["array", "null"]``), which the per-element
|
||||
style system uses for its per-mode override fields: null there means
|
||||
"inherit the base", so the type genuinely is "an array or nothing". A
|
||||
bare ``prop.get('type') == 'array'`` reads False for those, which meant
|
||||
the indexed colour inputs a form posts as ``...text_color.0/.1/.2`` were
|
||||
never recombined into a list.
|
||||
"""
|
||||
if not isinstance(prop, dict):
|
||||
return False
|
||||
declared = prop.get('type')
|
||||
if isinstance(declared, list):
|
||||
return wanted in declared
|
||||
return declared == wanted
|
||||
|
||||
|
||||
def _schema_allows_null(prop):
|
||||
"""Whether a schema property's declared type includes null."""
|
||||
return _schema_type_is(prop, 'null')
|
||||
|
||||
|
||||
def _parse_form_value_with_schema(value, key_path, schema):
|
||||
"""
|
||||
Parse a form value using schema information to determine correct type.
|
||||
@@ -828,11 +853,17 @@ def _parse_form_value_with_schema(value, key_path, schema):
|
||||
|
||||
# Handle None/empty values
|
||||
if value is None or (isinstance(value, str) and value.strip() == ''):
|
||||
# A nullable field left blank means null, not an empty container.
|
||||
# This is the inherit sentinel for per-mode style overrides: an
|
||||
# empty list there would read as "the user chose no colour" rather
|
||||
# than "follow the base element".
|
||||
if _schema_allows_null(prop):
|
||||
return None
|
||||
# If schema says it's an array, return empty array instead of None
|
||||
if prop and prop.get('type') == 'array':
|
||||
if prop and _schema_type_is(prop, 'array'):
|
||||
return []
|
||||
# If schema says it's an object, return empty dict instead of None
|
||||
if prop and prop.get('type') == 'object':
|
||||
if prop and _schema_type_is(prop, 'object'):
|
||||
return {}
|
||||
# If it's an optional string field, preserve empty string instead of None
|
||||
if prop and prop.get('type') == 'string':
|
||||
@@ -869,7 +900,7 @@ def _parse_form_value_with_schema(value, key_path, schema):
|
||||
return False
|
||||
|
||||
# Handle arrays based on schema
|
||||
if prop and prop.get('type') == 'array':
|
||||
if prop and _schema_type_is(prop, 'array'):
|
||||
# Try parsing as JSON first (handles "[1,2,3]" format)
|
||||
if stripped.startswith('['):
|
||||
try:
|
||||
@@ -892,7 +923,7 @@ def _parse_form_value_with_schema(value, key_path, schema):
|
||||
return []
|
||||
|
||||
# Handle objects based on schema
|
||||
if prop and prop.get('type') == 'object':
|
||||
if prop and _schema_type_is(prop, 'object'):
|
||||
# Try parsing as JSON
|
||||
if stripped.startswith('{'):
|
||||
try:
|
||||
@@ -995,8 +1026,10 @@ def _set_nested_value(config, key_path, value):
|
||||
current[seg] = {}
|
||||
current = current[seg]
|
||||
|
||||
# Set the final value (don't overwrite with empty dict if value is None and we want to preserve structure)
|
||||
if value is not None or segments[-1] not in current:
|
||||
# Set the final value. _SKIP_FIELD (checked above) is the only sentinel
|
||||
# for "leave the existing value alone" -- an explicit None here is a real
|
||||
# value (e.g. the per-mode "inherit the base" override) and must overwrite
|
||||
# whatever was already stored.
|
||||
current[segments[-1]] = value
|
||||
def _set_missing_booleans_to_false(config, schema_props, form_keys, prefix='', config_node=None):
|
||||
"""Walk schema and set missing boolean form fields to False.
|
||||
|
||||
@@ -83,6 +83,20 @@ def get_fonts_catalog():
|
||||
# Check if this is a system font (cannot be deleted)
|
||||
is_system = catalog_key.lower() in SYSTEM_FONTS
|
||||
|
||||
# BDF files are fixed-size bitmap strikes: FreeType
|
||||
# accepts only the pixel size baked into the file. The
|
||||
# UI needs to know that before offering a size control,
|
||||
# or it offers a number that cannot take effect.
|
||||
native_size = None
|
||||
if font_type == 'bdf':
|
||||
try:
|
||||
from src.element_style import _read_bdf_native_size
|
||||
native_size = _read_bdf_native_size(str(filepath))
|
||||
except Exception as e:
|
||||
logger.debug("Could not read native size for BDF font %s: %s",
|
||||
filepath, e)
|
||||
native_size = None
|
||||
|
||||
catalog[catalog_key] = {
|
||||
'filename': filename,
|
||||
'family_name': family_name,
|
||||
@@ -90,6 +104,8 @@ def get_fonts_catalog():
|
||||
'path': relative_path,
|
||||
'type': font_type,
|
||||
'is_system': is_system,
|
||||
'scalable': font_type != 'bdf',
|
||||
'native_size': native_size,
|
||||
'metadata': metadata if metadata else None
|
||||
}
|
||||
|
||||
@@ -124,39 +140,6 @@ def get_font_tokens():
|
||||
except Exception as e:
|
||||
logger.error('Unhandled exception', exc_info=True)
|
||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
||||
@api_v3.route('/fonts/overrides', methods=['GET'])
|
||||
def get_fonts_overrides():
|
||||
"""Get font overrides"""
|
||||
try:
|
||||
# This would integrate with the actual font system
|
||||
# For now, return empty overrides
|
||||
overrides = {}
|
||||
return jsonify({'status': 'success', 'data': {'overrides': overrides}})
|
||||
except Exception as e:
|
||||
logger.error('Unhandled exception', exc_info=True)
|
||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
||||
@api_v3.route('/fonts/overrides', methods=['POST'])
|
||||
def save_fonts_overrides():
|
||||
"""Save font overrides"""
|
||||
try:
|
||||
data = request.get_json(silent=True)
|
||||
if not data:
|
||||
return jsonify({'status': 'error', 'message': 'No data provided'}), 400
|
||||
|
||||
# This would integrate with the actual font system
|
||||
return jsonify({'status': 'success', 'message': 'Font overrides saved'})
|
||||
except Exception as e:
|
||||
logger.error('Unhandled exception', exc_info=True)
|
||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
||||
@api_v3.route('/fonts/overrides/<element_key>', methods=['DELETE'])
|
||||
def delete_font_override(element_key):
|
||||
"""Delete font override"""
|
||||
try:
|
||||
# This would integrate with the actual font system
|
||||
return jsonify({'status': 'success', 'message': f'Font override for {element_key} deleted'})
|
||||
except Exception as e:
|
||||
logger.error('Unhandled exception', exc_info=True)
|
||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
||||
@api_v3.route('/fonts/upload', methods=['POST'])
|
||||
def upload_font():
|
||||
"""Upload font file"""
|
||||
|
||||
@@ -10,7 +10,8 @@ from web_interface.blueprints.api_v3 import (
|
||||
_filter_config_by_schema, _get_plugin_version, _get_schema_property,
|
||||
_installed_plugin_ids, _is_plugin_update_available,
|
||||
_parse_form_value_with_schema, _prune_credential_backups,
|
||||
_run_calendar_registration, _set_missing_booleans_to_false,
|
||||
_run_calendar_registration, _schema_allows_null, _schema_type_is,
|
||||
_set_missing_booleans_to_false,
|
||||
_set_nested_value, _starlark_virtual_plugins, _toggle_starlark_app,
|
||||
api_v3, datetime, deep_merge, describe_exception, error_response,
|
||||
find_secret_fields, hashlib, json, jsonify, logger, logging,
|
||||
@@ -1781,7 +1782,7 @@ def save_plugin_config():
|
||||
if last_part.isdigit():
|
||||
# Get schema property for the base path to verify it's an array
|
||||
base_prop = _get_schema_property(schema, base_path)
|
||||
if base_prop and base_prop.get('type') == 'array':
|
||||
if base_prop and _schema_type_is(base_prop, 'array'):
|
||||
# This is an array index field
|
||||
index = int(last_part)
|
||||
if base_path not in array_fields:
|
||||
@@ -1796,6 +1797,16 @@ def save_plugin_config():
|
||||
# Sort by index and extract values
|
||||
index_values.sort(key=lambda x: x[0])
|
||||
values = [v for _, v in index_values]
|
||||
# Every channel blank on a nullable field means "unset", not
|
||||
# an empty array: joining them would produce ", , ", which
|
||||
# parses to [] and then fails the minItems the array
|
||||
# declares. This is how a per-mode colour override says
|
||||
# "inherit the base colour".
|
||||
base_prop_for_null = _get_schema_property(schema, base_path)
|
||||
if (_schema_allows_null(base_prop_for_null)
|
||||
and all(str(v).strip() == '' for v in values)):
|
||||
_set_nested_value(plugin_config, base_path, None)
|
||||
continue
|
||||
# Combine values into comma-separated string for parsing
|
||||
combined_value = ', '.join(str(v) for v in values)
|
||||
# Parse as array using schema
|
||||
|
||||
@@ -10,6 +10,8 @@ from pathlib import Path
|
||||
# Strict allowlists for URL-derived values used in path and script operations.
|
||||
_SAFE_PLUGIN_ID_RE = re.compile(r'^[a-zA-Z0-9_-]{1,64}$')
|
||||
_SAFE_WEB_UI_FILE_RE = re.compile(r'^[a-zA-Z0-9_-]{1,64}\.html$')
|
||||
_SAFE_WIDGET_NAME_RE = re.compile(r'^[a-zA-Z0-9_-]{1,64}$')
|
||||
_SAFE_WIDGET_SCRIPT_RE = re.compile(r'^[a-zA-Z0-9_-]{1,64}\.js$')
|
||||
from src.web_interface.secret_helpers import mask_secret_fields
|
||||
from src.common.path_safety import resolve_under, safe_path_component
|
||||
|
||||
@@ -19,6 +21,7 @@ logger = logging.getLogger(__name__)
|
||||
config_manager = None
|
||||
plugin_manager = None
|
||||
plugin_store_manager = None
|
||||
schema_manager = None
|
||||
|
||||
pages_v3 = Blueprint('pages_v3', __name__)
|
||||
|
||||
@@ -363,6 +366,119 @@ def serve_plugin_web_ui(plugin_id, filename):
|
||||
logger.error('Error serving plugin web_ui %s/%s', plugin_id, filename, exc_info=True)
|
||||
return 'Error serving file', 500, {'Content-Type': 'text/plain'}
|
||||
|
||||
|
||||
def _plugin_dir_for(safe_id):
|
||||
"""Resolve a sanitised plugin id to its directory, or None.
|
||||
|
||||
Mirrors serve_plugin_web_ui: containment-guarded against the configured
|
||||
plugins directory, with PluginManager's ``ledmatrix-`` prefix fallback.
|
||||
"""
|
||||
plugins_base = Path(pages_v3.plugin_manager.plugins_dir).resolve()
|
||||
plugin_dir = resolve_under(plugins_base, safe_id)
|
||||
if plugin_dir is None:
|
||||
raise ValueError('plugin id escapes the plugins directory')
|
||||
|
||||
if not plugin_dir.exists():
|
||||
alt = resolve_under(plugins_base, f'ledmatrix-{safe_id}')
|
||||
if alt is not None:
|
||||
plugin_dir = alt
|
||||
return plugin_dir
|
||||
|
||||
|
||||
def _declared_widget_script(plugin_dir, widget_name):
|
||||
"""The script filename a plugin's manifest declares for ``widget_name``.
|
||||
|
||||
The manifest is the allowlist: only a widget the plugin actually declares
|
||||
can be served, so this route never exposes arbitrary files under the
|
||||
plugin directory even though the directory itself is attacker-influenced
|
||||
(plugins are user-installed). Returns None when the widget is not
|
||||
declared, the manifest is unreadable, or the declared script name is not
|
||||
a plain ``<name>.js`` basename.
|
||||
"""
|
||||
manifest_path = plugin_dir / 'manifest.json'
|
||||
try:
|
||||
with open(manifest_path, 'r', encoding='utf-8') as f:
|
||||
manifest = json.load(f)
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
if not isinstance(manifest, dict):
|
||||
return None
|
||||
|
||||
for entry in manifest.get('widgets') or ():
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
if entry.get('name') != widget_name:
|
||||
continue
|
||||
script = entry.get('script') or f'{widget_name}.js'
|
||||
if not isinstance(script, str) or not _SAFE_WIDGET_SCRIPT_RE.match(script):
|
||||
return None
|
||||
return script
|
||||
return None
|
||||
|
||||
|
||||
@pages_v3.route('/static/plugin-widgets/<plugin_id>/<widget_name>.js')
|
||||
def serve_plugin_widget(plugin_id, widget_name):
|
||||
"""Serve a plugin-declared widget script from its ``widgets/`` directory.
|
||||
|
||||
This is the server half of ``LEDMatrixWidgets.loadPluginWidget`` (see
|
||||
static/v3/js/widgets/plugin-loader.js), which fetches exactly this path.
|
||||
The loader uses a dynamic ``import()``, so the response must carry a
|
||||
JavaScript MIME type or the browser refuses the module.
|
||||
|
||||
The route is deliberately narrower than the plugin directory: a script is
|
||||
served only when the plugin's own manifest declares a widget by that name,
|
||||
so installing a plugin does not publish everything it ships.
|
||||
"""
|
||||
if not _SAFE_PLUGIN_ID_RE.match(plugin_id):
|
||||
return 'Invalid plugin ID', 400, {'Content-Type': 'text/plain'}
|
||||
if not _SAFE_WIDGET_NAME_RE.match(widget_name):
|
||||
return 'Invalid widget name', 400, {'Content-Type': 'text/plain'}
|
||||
|
||||
# safe_path_component is this codebase's sanitiser (src/common/
|
||||
# path_safety.py): it rejects rather than mangles, so a name that is not
|
||||
# a plain path component never reaches the filesystem.
|
||||
safe_id = safe_path_component(plugin_id)
|
||||
safe_widget = safe_path_component(widget_name)
|
||||
if not safe_id or not safe_widget:
|
||||
return 'Invalid path component', 400, {'Content-Type': 'text/plain'}
|
||||
|
||||
if not pages_v3.plugin_manager:
|
||||
return 'Plugin manager not available', 503, {'Content-Type': 'text/plain'}
|
||||
|
||||
try:
|
||||
plugin_dir = _plugin_dir_for(safe_id)
|
||||
if not plugin_dir.exists():
|
||||
return 'Not found', 404, {'Content-Type': 'text/plain'}
|
||||
|
||||
script = _declared_widget_script(plugin_dir, safe_widget)
|
||||
if script is None:
|
||||
# Undeclared is a 404 rather than a 403: whether a plugin happens
|
||||
# to ship an undeclared file is not something to confirm.
|
||||
return 'Not found', 404, {'Content-Type': 'text/plain'}
|
||||
|
||||
widgets_dir = (plugin_dir / 'widgets').resolve()
|
||||
# The script name comes from the plugin's manifest, not the request,
|
||||
# so it gets the same containment treatment the URL parts got.
|
||||
script_path = resolve_under(widgets_dir, script)
|
||||
if script_path is None or not script_path.is_file():
|
||||
return 'Not found', 404, {'Content-Type': 'text/plain'}
|
||||
|
||||
body = script_path.read_text(encoding='utf-8')
|
||||
return body, 200, {
|
||||
'Content-Type': 'text/javascript; charset=utf-8',
|
||||
# Plugin updates replace this file in place; revalidate so a
|
||||
# stale widget cannot outlive the plugin version that shipped it.
|
||||
'Cache-Control': 'no-cache',
|
||||
}
|
||||
|
||||
except ValueError:
|
||||
return 'Forbidden', 403, {'Content-Type': 'text/plain'}
|
||||
except Exception:
|
||||
logger.error('Error serving plugin widget %s/%s', plugin_id, widget_name,
|
||||
exc_info=True)
|
||||
return 'Error serving file', 500, {'Content-Type': 'text/plain'}
|
||||
|
||||
|
||||
def _load_overview_partial():
|
||||
"""Load overview partial with system stats"""
|
||||
try:
|
||||
@@ -715,8 +831,35 @@ def _load_plugin_config_partial(plugin_id):
|
||||
except Exception as e: # nosec B110 - metadata pre-load is optional; schema loads fully below
|
||||
logger.debug("Metadata pre-load skipped for plugin %s: %s", plugin_id, e)
|
||||
|
||||
# Get plugin schema
|
||||
# Get plugin schema.
|
||||
#
|
||||
# Through SchemaManager, not a raw json.load, because that is what
|
||||
# the save route uses (api_v3.save_plugin_config) -- and the two
|
||||
# disagreeing is not academic. SchemaManager applies
|
||||
# expand_style_elements, which turns a compact
|
||||
# customization.x-style-elements declaration into the per-element
|
||||
# blocks this form renders. Reading the file directly meant a plugin
|
||||
# using that form (of-the-day ships one) had a customization section
|
||||
# that rendered nothing at all, while saving still validated against
|
||||
# the expanded shape.
|
||||
#
|
||||
# use_cache=False matches the save route: a plugin's schema changes
|
||||
# on disk during development, and a cached copy would keep serving
|
||||
# the old form.
|
||||
#
|
||||
# The raw read stays as a fallback for callers that never set a
|
||||
# schema_manager (several tests, and any embedder of this blueprint).
|
||||
schema = {}
|
||||
schema_mgr = getattr(pages_v3, 'schema_manager', None)
|
||||
if schema_mgr is not None:
|
||||
try:
|
||||
schema = schema_mgr.load_schema(plugin_id, use_cache=False) or {}
|
||||
except Exception as e:
|
||||
logger.warning("SchemaManager could not load schema for %s: %s",
|
||||
plugin_id, e)
|
||||
if not schema:
|
||||
# resolve_under keeps the containment guard main added here; the
|
||||
# SchemaManager path above does its own.
|
||||
schema_path = resolve_under(_plugin_dir, "config_schema.json")
|
||||
if schema_path is not None and schema_path.exists():
|
||||
try:
|
||||
|
||||
@@ -1412,3 +1412,85 @@ button.bg-white {
|
||||
max-width: calc(100vw - 2rem);
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- Style editor widget -------------------------------------------- */
|
||||
/* One row per display element: label, font, size, colour, X, Y. The grid
|
||||
is declared once here so the header and the rows cannot drift apart. */
|
||||
/* The plugin config panel is narrower than nine columns of controls, and a
|
||||
clipped table hides the offsets entirely -- scroll the table rather than
|
||||
crushing the controls or dropping columns. */
|
||||
.style-editor {
|
||||
overflow-x: auto;
|
||||
}
|
||||
.style-editor-table {
|
||||
min-width: max-content;
|
||||
}
|
||||
/* Scrolled right, the element names would otherwise scroll away and the
|
||||
row you are editing becomes anonymous. */
|
||||
.style-editor-label,
|
||||
.style-editor-head > :first-child {
|
||||
position: sticky;
|
||||
left: 0;
|
||||
z-index: 1;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
.style-editor-head > :first-child {
|
||||
background-color: #f9fafb;
|
||||
}
|
||||
.style-editor-row {
|
||||
/* Set by the widget: the column count depends on what the plugin
|
||||
declares, so the track list cannot be fixed here. */
|
||||
grid-template-columns: var(--style-editor-columns,
|
||||
minmax(7rem, 1.4fr) minmax(8rem, 2fr) 5rem 4.5rem 5rem 5rem);
|
||||
}
|
||||
.style-editor-check {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
.style-editor-row .form-control {
|
||||
padding: 0.2rem 0.4rem;
|
||||
height: auto;
|
||||
}
|
||||
.style-editor-row input[type="number"] {
|
||||
width: 100%;
|
||||
}
|
||||
.style-editor-row input[type="number"]:disabled {
|
||||
background-color: #f3f4f6;
|
||||
color: #9ca3af;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.style-editor-colour {
|
||||
width: 2rem;
|
||||
height: 1.6rem;
|
||||
padding: 0;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 0.25rem;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
.style-editor-tab {
|
||||
border: 1px solid #d1d5db;
|
||||
background-color: #f9fafb;
|
||||
color: #374151;
|
||||
}
|
||||
.style-editor-tab.is-active {
|
||||
background-color: #2563eb;
|
||||
border-color: #2563eb;
|
||||
color: #ffffff;
|
||||
}
|
||||
.style-editor-panel[hidden] {
|
||||
display: none;
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
/* Stack rather than squeeze: six columns on a phone is unreadable. */
|
||||
.style-editor-row {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
.style-editor-head {
|
||||
display: none;
|
||||
}
|
||||
.style-editor-label {
|
||||
grid-column: 1 / -1;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -752,586 +752,6 @@
|
||||
}
|
||||
},
|
||||
|
||||
generateConfigForm(pluginId, config, schema, webUiActions = []) {
|
||||
// Safety check - if schema/config not ready, return empty
|
||||
if (!pluginId || !config) {
|
||||
return '<div class="text-gray-500">Loading configuration...</div>';
|
||||
}
|
||||
|
||||
// Only log once per plugin to avoid spam (Alpine.js may call this multiple times during rendering)
|
||||
if (!this._configFormLogged || this._configFormLogged !== pluginId) {
|
||||
debugLog('[DEBUG] generateConfigForm called for', pluginId, 'with', webUiActions?.length || 0, 'actions');
|
||||
// Debug: Check if image_config.images has x-widget in schema
|
||||
if (schema && schema.properties && schema.properties.image_config) {
|
||||
const imgConfig = schema.properties.image_config;
|
||||
if (imgConfig.properties && imgConfig.properties.images) {
|
||||
const imagesProp = imgConfig.properties.images;
|
||||
debugLog('[DEBUG] Schema check - image_config.images:', {
|
||||
type: imagesProp.type,
|
||||
'x-widget': imagesProp['x-widget'],
|
||||
'has x-widget': 'x-widget' in imagesProp,
|
||||
keys: Object.keys(imagesProp)
|
||||
});
|
||||
}
|
||||
}
|
||||
this._configFormLogged = pluginId;
|
||||
}
|
||||
if (!schema || !schema.properties) {
|
||||
return this.generateSimpleConfigForm(config, webUiActions, pluginId);
|
||||
}
|
||||
|
||||
// Helper function to get schema property by full key path
|
||||
const getSchemaProperty = (schemaObj, keyPath) => {
|
||||
if (!schemaObj || !schemaObj.properties) return null;
|
||||
const keys = keyPath.split('.');
|
||||
let current = schemaObj.properties;
|
||||
for (let i = 0; i < keys.length; i++) {
|
||||
const k = keys[i];
|
||||
if (!current || !current[k]) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const prop = current[k];
|
||||
// If this is the last key, return the property
|
||||
if (i === keys.length - 1) {
|
||||
return prop;
|
||||
}
|
||||
|
||||
// If this property has nested properties, navigate deeper
|
||||
if (prop && typeof prop === 'object' && prop.properties) {
|
||||
current = prop.properties;
|
||||
} else {
|
||||
// Can't navigate deeper
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const generateFieldHtml = (key, prop, value, prefix = '') => {
|
||||
const fullKey = prefix ? `${prefix}.${key}` : key;
|
||||
const label = prop.title || key.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase());
|
||||
const description = prop.description || '';
|
||||
let html = '';
|
||||
|
||||
// Debug: Log property structure for arrays to help diagnose file-upload widget issues
|
||||
if (prop.type === 'array') {
|
||||
// Also check schema directly as fallback
|
||||
const schemaProp = getSchemaProperty(schema, fullKey);
|
||||
const xWidgetFromSchema = schemaProp ? (schemaProp['x-widget'] || schemaProp['x_widget']) : null;
|
||||
|
||||
debugLog('[DEBUG generateFieldHtml] Array property:', fullKey, {
|
||||
'prop.x-widget': prop['x-widget'],
|
||||
'prop.x_widget': prop['x_widget'],
|
||||
'schema.x-widget': xWidgetFromSchema,
|
||||
'hasOwnProperty(x-widget)': prop.hasOwnProperty('x-widget'),
|
||||
'x-widget in prop': 'x-widget' in prop,
|
||||
'all prop keys': Object.keys(prop),
|
||||
'schemaProp keys': schemaProp ? Object.keys(schemaProp) : 'null'
|
||||
});
|
||||
}
|
||||
|
||||
// Handle nested objects
|
||||
if (prop.type === 'object' && prop.properties) {
|
||||
const sectionId = `section-${fullKey.replace(/\./g, '-')}`;
|
||||
const nestedConfig = value || {};
|
||||
const sectionLabel = prop.title || key.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase());
|
||||
// Calculate nesting depth for better spacing
|
||||
const nestingDepth = (fullKey.match(/\./g) || []).length;
|
||||
const marginClass = nestingDepth > 1 ? 'mb-6' : 'mb-4';
|
||||
|
||||
html += `
|
||||
<div class="nested-section border border-gray-300 rounded-lg ${marginClass}">
|
||||
<button type="button"
|
||||
class="w-full bg-gray-100 hover:bg-gray-200 px-4 py-3 flex items-center justify-between text-left transition-colors"
|
||||
onclick="toggleNestedSection('${sectionId}', event); return false;">
|
||||
<div class="flex-1">
|
||||
<h4 class="font-semibold text-gray-900">${sectionLabel}</h4>
|
||||
${description ? `<p class="text-sm text-gray-600 mt-1">${description}</p>` : ''}
|
||||
</div>
|
||||
<i id="${sectionId}-icon" class="fas fa-chevron-right text-gray-500 transition-transform"></i>
|
||||
</button>
|
||||
<div id="${sectionId}" class="nested-content collapsed bg-gray-50 px-4 py-4 space-y-3" style="max-height: 0; display: none;">
|
||||
`;
|
||||
|
||||
// Recursively generate fields for nested properties
|
||||
// Get ordered properties if x-propertyOrder is defined
|
||||
let nestedPropertyEntries = Object.entries(prop.properties);
|
||||
if (prop['x-propertyOrder'] && Array.isArray(prop['x-propertyOrder'])) {
|
||||
const order = prop['x-propertyOrder'];
|
||||
const orderedEntries = [];
|
||||
const unorderedEntries = [];
|
||||
|
||||
// Separate ordered and unordered properties
|
||||
nestedPropertyEntries.forEach(([nestedKey, nestedProp]) => {
|
||||
const index = order.indexOf(nestedKey);
|
||||
if (index !== -1) {
|
||||
orderedEntries[index] = [nestedKey, nestedProp];
|
||||
} else {
|
||||
unorderedEntries.push([nestedKey, nestedProp]);
|
||||
}
|
||||
});
|
||||
|
||||
// Combine ordered entries (filter out undefined from sparse array) with unordered entries
|
||||
nestedPropertyEntries = orderedEntries.filter(entry => entry !== undefined).concat(unorderedEntries);
|
||||
}
|
||||
|
||||
nestedPropertyEntries.forEach(([nestedKey, nestedProp]) => {
|
||||
// Use config value if it exists and is not null (including false), otherwise use schema default
|
||||
// Check if key exists in config and value is not null/undefined
|
||||
const hasValue = nestedKey in nestedConfig && nestedConfig[nestedKey] !== null && nestedConfig[nestedKey] !== undefined;
|
||||
// For nested objects, if the value is an empty object, still use it (don't fall back to default)
|
||||
const isNestedObject = nestedProp.type === 'object' && nestedProp.properties;
|
||||
const nestedValue = hasValue ? nestedConfig[nestedKey] :
|
||||
(nestedProp.default !== undefined ? nestedProp.default :
|
||||
(isNestedObject ? {} : (nestedProp.type === 'array' ? [] : (nestedProp.type === 'boolean' ? false : ''))));
|
||||
|
||||
// Debug logging for file-upload widgets
|
||||
if (nestedProp.type === 'array' && (nestedProp['x-widget'] === 'file-upload' || nestedProp['x_widget'] === 'file-upload')) {
|
||||
debugLog('[DEBUG] Found file-upload widget in nested property:', nestedKey, 'fullKey:', fullKey + '.' + nestedKey, 'prop:', nestedProp);
|
||||
}
|
||||
|
||||
html += generateFieldHtml(nestedKey, nestedProp, nestedValue, fullKey);
|
||||
});
|
||||
|
||||
html += `
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Add extra spacing after nested sections to prevent overlap with next section
|
||||
if (nestingDepth > 0) {
|
||||
html += `<div class="mb-2"></div>`;
|
||||
}
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
// Regular (non-nested) field
|
||||
html += `<div class="form-group">`;
|
||||
html += `<label class="block text-sm font-medium text-gray-700 mb-1">${label}</label>`;
|
||||
|
||||
if (description) {
|
||||
html += `<p class="text-sm text-gray-600 mb-2">${description}</p>`;
|
||||
}
|
||||
|
||||
// Generate appropriate input based on type
|
||||
if (prop.type === 'boolean') {
|
||||
html += `<label class="flex items-center">`;
|
||||
html += `<input type="checkbox" name="${fullKey}" ${value ? 'checked' : ''} class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">`;
|
||||
html += `<span class="ml-2 text-sm">Enabled</span>`;
|
||||
html += `</label>`;
|
||||
} else if (prop.type === 'number' || prop.type === 'integer' ||
|
||||
(Array.isArray(prop.type) && (prop.type.includes('number') || prop.type.includes('integer')))) {
|
||||
// Handle union types like ["integer", "null"]
|
||||
const isUnionType = Array.isArray(prop.type);
|
||||
const allowsNull = isUnionType && prop.type.includes('null');
|
||||
const isInteger = prop.type === 'integer' || (isUnionType && prop.type.includes('integer'));
|
||||
const isNumber = prop.type === 'number' || (isUnionType && prop.type.includes('number'));
|
||||
const min = prop.minimum !== undefined ? `min="${prop.minimum}"` : '';
|
||||
const max = prop.maximum !== undefined ? `max="${prop.maximum}"` : '';
|
||||
const step = isInteger ? 'step="1"' : 'step="any"';
|
||||
|
||||
// For union types with null, don't show default if value is null (leave empty)
|
||||
// This allows users to explicitly set null by leaving it empty
|
||||
let fieldValue = '';
|
||||
if (value !== undefined && value !== null) {
|
||||
fieldValue = value;
|
||||
} else if (!allowsNull && prop.default !== undefined) {
|
||||
// Only use default if null is not allowed
|
||||
fieldValue = prop.default;
|
||||
}
|
||||
|
||||
// Ensure value respects min/max constraints
|
||||
if (fieldValue !== '' && fieldValue !== undefined && fieldValue !== null) {
|
||||
const numValue = typeof fieldValue === 'string' ? parseFloat(fieldValue) : fieldValue;
|
||||
if (!isNaN(numValue)) {
|
||||
// Clamp value to min/max if constraints exist
|
||||
if (prop.minimum !== undefined && numValue < prop.minimum) {
|
||||
fieldValue = prop.minimum;
|
||||
} else if (prop.maximum !== undefined && numValue > prop.maximum) {
|
||||
fieldValue = prop.maximum;
|
||||
} else {
|
||||
fieldValue = numValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add placeholder/help text for null-able fields
|
||||
const placeholder = allowsNull ? 'Leave empty to use current time (random)' : '';
|
||||
const helpText = allowsNull && description && description.includes('null') ?
|
||||
`<p class="text-xs text-gray-500 mt-1">${description}</p>` : '';
|
||||
|
||||
html += `<input type="number" name="${fullKey}" value="${fieldValue}" ${min} ${max} ${step} placeholder="${placeholder}" class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm">`;
|
||||
if (helpText) {
|
||||
html += helpText;
|
||||
}
|
||||
} else if (prop.type === 'array') {
|
||||
// AGGRESSIVE file upload widget detection
|
||||
// For 'images' field in static-image plugin, always check schema directly
|
||||
let isFileUpload = false;
|
||||
let uploadConfig = {};
|
||||
|
||||
// Direct check: if this is the 'images' field and schema has it with x-widget
|
||||
if (fullKey === 'images' && schema && schema.properties && schema.properties.images) {
|
||||
const imagesSchema = schema.properties.images;
|
||||
if (imagesSchema['x-widget'] === 'file-upload' || imagesSchema['x_widget'] === 'file-upload') {
|
||||
isFileUpload = true;
|
||||
uploadConfig = imagesSchema['x-upload-config'] || imagesSchema['x_upload_config'] || {};
|
||||
debugLog('[DEBUG] ✅ Direct detection: images field has file-upload widget', uploadConfig);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: check prop object (should have x-widget if schema loaded correctly)
|
||||
if (!isFileUpload) {
|
||||
const xWidgetFromProp = prop['x-widget'] || prop['x_widget'] || prop.xWidget;
|
||||
if (xWidgetFromProp === 'file-upload') {
|
||||
isFileUpload = true;
|
||||
uploadConfig = prop['x-upload-config'] || prop['x_upload_config'] || {};
|
||||
debugLog('[DEBUG] ✅ Detection via prop object');
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: schema property lookup
|
||||
if (!isFileUpload) {
|
||||
let schemaProp = getSchemaProperty(schema, fullKey);
|
||||
if (!schemaProp && fullKey === 'images' && schema && schema.properties && schema.properties.images) {
|
||||
schemaProp = schema.properties.images;
|
||||
}
|
||||
const xWidgetFromSchema = schemaProp ? (schemaProp['x-widget'] || schemaProp['x_widget']) : null;
|
||||
if (xWidgetFromSchema === 'file-upload') {
|
||||
isFileUpload = true;
|
||||
uploadConfig = schemaProp['x-upload-config'] || schemaProp['x_upload_config'] || {};
|
||||
debugLog('[DEBUG] ✅ Detection via schema lookup');
|
||||
}
|
||||
}
|
||||
|
||||
// Debug logging for ALL array fields to diagnose
|
||||
debugLog('[DEBUG] Array field check:', fullKey, {
|
||||
'isFileUpload': isFileUpload,
|
||||
'prop keys': Object.keys(prop),
|
||||
'prop.x-widget': prop['x-widget'],
|
||||
'schema.properties.images exists': !!(schema && schema.properties && schema.properties.images),
|
||||
'schema.properties.images.x-widget': (schema && schema.properties && schema.properties.images) ? schema.properties.images['x-widget'] : null,
|
||||
'uploadConfig': uploadConfig
|
||||
});
|
||||
|
||||
if (isFileUpload) {
|
||||
debugLog('[DEBUG] ✅ Rendering file-upload widget for', fullKey, 'with config:', uploadConfig);
|
||||
// Use the file upload widget from plugins.html
|
||||
// We'll need to call a function that exists in the global scope
|
||||
const maxFiles = uploadConfig.max_files || 10;
|
||||
const allowedTypes = uploadConfig.allowed_types || ['image/png', 'image/jpeg', 'image/bmp', 'image/gif'];
|
||||
const maxSizeMB = uploadConfig.max_size_mb || 5;
|
||||
|
||||
const currentImages = Array.isArray(value) ? value : [];
|
||||
const fieldId = fullKey.replace(/\./g, '_');
|
||||
const safePluginId = (uploadConfig.plugin_id || pluginId || 'static-image').toString().replace(/[^a-zA-Z0-9_-]/g, '_');
|
||||
|
||||
html += `
|
||||
<div id="${fieldId}_upload_widget" class="mt-1">
|
||||
<!-- File Upload Drop Zone -->
|
||||
<div id="${fieldId}_drop_zone"
|
||||
class="border-2 border-dashed border-gray-300 rounded-lg p-6 text-center hover:border-blue-400 transition-colors cursor-pointer"
|
||||
ondrop="window.handleFileDrop(event, this.dataset.fieldId)"
|
||||
ondragover="event.preventDefault()"
|
||||
data-field-id="${fieldId}"
|
||||
onclick="document.getElementById(this.dataset.fieldId + '_file_input').click()">
|
||||
<input type="file"
|
||||
id="${fieldId}_file_input"
|
||||
multiple
|
||||
accept="${allowedTypes.join(',')}"
|
||||
style="display: none;"
|
||||
data-field-id="${fieldId}"
|
||||
onchange="window.handleFileSelect(event, this.dataset.fieldId)">
|
||||
<i class="fas fa-cloud-upload-alt text-3xl text-gray-400 mb-2"></i>
|
||||
<p class="text-sm text-gray-600">Drag and drop images here or click to browse</p>
|
||||
<p class="text-xs text-gray-500 mt-1">Max ${maxFiles} files, ${maxSizeMB}MB each (PNG, JPG, GIF, BMP)</p>
|
||||
</div>
|
||||
|
||||
<!-- Uploaded Images List -->
|
||||
<div id="${fieldId}_image_list" class="mt-4 space-y-2">
|
||||
${currentImages.map((img, idx) => {
|
||||
const imgSchedule = img.schedule || {};
|
||||
const hasSchedule = imgSchedule.enabled && imgSchedule.mode && imgSchedule.mode !== 'always';
|
||||
let scheduleSummary = 'Always shown';
|
||||
if (hasSchedule && window.getScheduleSummary) {
|
||||
try {
|
||||
scheduleSummary = window.getScheduleSummary(imgSchedule) || 'Scheduled';
|
||||
} catch (e) {
|
||||
scheduleSummary = 'Scheduled';
|
||||
}
|
||||
} else if (hasSchedule) {
|
||||
scheduleSummary = 'Scheduled';
|
||||
}
|
||||
// Escape the summary for HTML
|
||||
scheduleSummary = String(scheduleSummary).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
|
||||
return `
|
||||
<div id="img_${(img.id || idx).toString().replace(/[^a-zA-Z0-9_-]/g, '_')}" class="bg-gray-50 p-3 rounded-lg border border-gray-200">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div class="flex items-center space-x-3 flex-1">
|
||||
<img src="/${(img.path || '').replace(/&/g, '&').replace(/"/g, '"')}"
|
||||
alt="${(img.filename || '').replace(/"/g, '"')}"
|
||||
class="w-16 h-16 object-cover rounded"
|
||||
onerror="this.style.display='none'; this.nextElementSibling.style.display='block';">
|
||||
<div style="display:none;" class="w-16 h-16 bg-gray-200 rounded flex items-center justify-center">
|
||||
<i class="fas fa-image text-gray-400"></i>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium text-gray-900 truncate">${String(img.original_filename || img.filename || 'Image').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')}</p>
|
||||
<p class="text-xs text-gray-500">${img.size ? (Math.round(img.size / 1024) + ' KB') : ''} • ${(img.uploaded_at || '').replace(/&/g, '&')}</p>
|
||||
<p class="text-xs text-blue-600 mt-1">
|
||||
<i class="fas fa-clock mr-1"></i>${scheduleSummary}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2 ml-4">
|
||||
<button type="button"
|
||||
data-field-id="${fieldId}"
|
||||
data-image-id="${img.id || ''}"
|
||||
data-image-idx="${idx}"
|
||||
onclick="window.openImageSchedule(this.dataset.fieldId, this.dataset.imageId || null, parseInt(this.dataset.imageIdx))"
|
||||
class="text-blue-600 hover:text-blue-800 p-2"
|
||||
title="Schedule this image">
|
||||
<i class="fas fa-calendar-alt"></i>
|
||||
</button>
|
||||
<button type="button"
|
||||
data-field-id="${fieldId}"
|
||||
data-image-id="${img.id || ''}"
|
||||
data-plugin-id="${safePluginId}"
|
||||
onclick="window.deleteUploadedImage(this.dataset.fieldId, this.dataset.imageId, this.dataset.pluginId)"
|
||||
class="text-red-600 hover:text-red-800 p-2"
|
||||
title="Delete image">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Schedule widget will be inserted here when opened -->
|
||||
<div id="schedule_${(img.id || idx).toString().replace(/[^a-zA-Z0-9_-]/g, '_')}" class="hidden mt-3 pt-3 border-t border-gray-300"></div>
|
||||
</div>
|
||||
`;
|
||||
}).join('')}
|
||||
</div>
|
||||
|
||||
<!-- Hidden input to store image data -->
|
||||
<input type="hidden" id="${fieldId}_images_data" name="${fullKey}" value="${JSON.stringify(currentImages).replace(/&/g, '&').replace(/"/g, '"').replace(/'/g, ''')}">
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
// Regular array input
|
||||
const arrayValue = Array.isArray(value) ? value.join(', ') : '';
|
||||
html += `<input type="text" name="${fullKey}" value="${arrayValue}" placeholder="Enter values separated by commas" class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm">`;
|
||||
html += `<p class="text-sm text-gray-600 mt-1">Enter values separated by commas</p>`;
|
||||
}
|
||||
} else if (prop.enum) {
|
||||
html += `<select name="${fullKey}" class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm">`;
|
||||
prop.enum.forEach(option => {
|
||||
const selected = value === option ? 'selected' : '';
|
||||
html += `<option value="${option}" ${selected}>${option}</option>`;
|
||||
});
|
||||
html += `</select>`;
|
||||
} else if (prop.type === 'string' && prop['x-widget'] === 'file-upload') {
|
||||
// File upload widget for string fields (e.g., credentials.json)
|
||||
const uploadConfig = prop['x-upload-config'] || {};
|
||||
const uploadEndpoint = uploadConfig.upload_endpoint || '/api/v3/plugins/assets/upload';
|
||||
const maxSizeMB = uploadConfig.max_size_mb || 1;
|
||||
const allowedExtensions = uploadConfig.allowed_extensions || ['.json'];
|
||||
const targetFilename = uploadConfig.target_filename || 'file.json';
|
||||
const fieldId = fullKey.replace(/\./g, '_');
|
||||
const hasFile = value && value !== '';
|
||||
|
||||
html += `
|
||||
<div id="${fieldId}_upload_widget" class="mt-1">
|
||||
<div id="${fieldId}_file_upload"
|
||||
class="border-2 border-dashed border-gray-300 rounded-lg p-4 text-center hover:border-blue-400 transition-colors cursor-pointer"
|
||||
onclick="document.getElementById('${fieldId}_file_input').click()">
|
||||
<input type="file"
|
||||
id="${fieldId}_file_input"
|
||||
accept="${allowedExtensions.join(',')}"
|
||||
style="display: none;"
|
||||
data-field-id="${fieldId}"
|
||||
data-upload-endpoint="${uploadEndpoint}"
|
||||
data-target-filename="${targetFilename}"
|
||||
onchange="window.handleCredentialsUpload(event, this.dataset.fieldId, this.dataset.uploadEndpoint, this.dataset.targetFilename)">
|
||||
<i class="fas fa-file-upload text-2xl text-gray-400 mb-2"></i>
|
||||
<p class="text-sm text-gray-600" id="${fieldId}_status">
|
||||
${hasFile ? `Current file: ${value}` : 'Click to upload ' + targetFilename}
|
||||
</p>
|
||||
<p class="text-xs text-gray-500 mt-1">Max ${maxSizeMB}MB (${allowedExtensions.join(', ')})</p>
|
||||
</div>
|
||||
<input type="hidden" name="${fullKey}" value="${value || ''}" id="${fieldId}_hidden">
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
// Default to text input
|
||||
const maxLength = prop.maxLength || '';
|
||||
const maxLengthAttr = maxLength ? `maxlength="${maxLength}"` : '';
|
||||
html += `<input type="text" name="${fullKey}" value="${value !== undefined ? value : ''}" ${maxLengthAttr} class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm">`;
|
||||
}
|
||||
|
||||
html += `</div>`;
|
||||
return html;
|
||||
};
|
||||
|
||||
let formHtml = '';
|
||||
// Get ordered properties if x-propertyOrder is defined
|
||||
let propertyEntries = Object.entries(schema.properties);
|
||||
if (schema['x-propertyOrder'] && Array.isArray(schema['x-propertyOrder'])) {
|
||||
const order = schema['x-propertyOrder'];
|
||||
const orderedEntries = [];
|
||||
const unorderedEntries = [];
|
||||
|
||||
// Separate ordered and unordered properties
|
||||
propertyEntries.forEach(([key, prop]) => {
|
||||
const index = order.indexOf(key);
|
||||
if (index !== -1) {
|
||||
orderedEntries[index] = [key, prop];
|
||||
} else {
|
||||
unorderedEntries.push([key, prop]);
|
||||
}
|
||||
});
|
||||
|
||||
// Combine ordered entries (filter out undefined from sparse array) with unordered entries
|
||||
propertyEntries = orderedEntries.filter(entry => entry !== undefined).concat(unorderedEntries);
|
||||
}
|
||||
|
||||
propertyEntries.forEach(([key, prop]) => {
|
||||
// Skip the 'enabled' property - it's managed separately via the header toggle
|
||||
if (key === 'enabled') return;
|
||||
// Use config value if key exists and is not null/undefined, otherwise use schema default
|
||||
// Check if key exists in config and value is not null/undefined
|
||||
const hasValue = key in config && config[key] !== null && config[key] !== undefined;
|
||||
// For nested objects, if the value is an empty object, still use it (don't fall back to default)
|
||||
const isNestedObject = prop.type === 'object' && prop.properties;
|
||||
const value = hasValue ? config[key] :
|
||||
(prop.default !== undefined ? prop.default :
|
||||
(isNestedObject ? {} : (prop.type === 'array' ? [] : (prop.type === 'boolean' ? false : ''))));
|
||||
formHtml += generateFieldHtml(key, prop, value);
|
||||
});
|
||||
|
||||
// Add web UI actions section if plugin defines any
|
||||
if (webUiActions && webUiActions.length > 0) {
|
||||
debugLog('[DEBUG] Rendering', webUiActions.length, 'actions in tab form');
|
||||
|
||||
// Map color names to explicit Tailwind classes
|
||||
const colorMap = {
|
||||
'blue': { bg: 'bg-blue-50', border: 'border-blue-200', text: 'text-blue-900', textLight: 'text-blue-700', btn: 'bg-blue-600 hover:bg-blue-700' },
|
||||
'green': { bg: 'bg-green-50', border: 'border-green-200', text: 'text-green-900', textLight: 'text-green-700', btn: 'bg-green-600 hover:bg-green-700' },
|
||||
'red': { bg: 'bg-red-50', border: 'border-red-200', text: 'text-red-900', textLight: 'text-red-700', btn: 'bg-red-600 hover:bg-red-700' },
|
||||
'yellow': { bg: 'bg-yellow-50', border: 'border-yellow-200', text: 'text-yellow-900', textLight: 'text-yellow-700', btn: 'bg-yellow-600 hover:bg-yellow-700' },
|
||||
'purple': { bg: 'bg-purple-50', border: 'border-purple-200', text: 'text-purple-900', textLight: 'text-purple-700', btn: 'bg-purple-600 hover:bg-purple-700' }
|
||||
};
|
||||
|
||||
formHtml += `
|
||||
<div class="border-t border-gray-200 pt-4 mt-4">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-3">Actions</h3>
|
||||
<p class="text-sm text-gray-600 mb-4">${webUiActions[0].section_description || 'Perform actions for this plugin'}</p>
|
||||
|
||||
<div class="space-y-3">
|
||||
`;
|
||||
|
||||
webUiActions.forEach((action, index) => {
|
||||
const actionId = `action-${action.id}-${index}`;
|
||||
const statusId = `action-status-${action.id}-${index}`;
|
||||
const bgColor = action.color || 'blue';
|
||||
const colors = colorMap[bgColor] || colorMap['blue'];
|
||||
// Ensure pluginId is valid for template interpolation
|
||||
const safePluginId = pluginId || '';
|
||||
|
||||
formHtml += `
|
||||
<div class="${colors.bg} border ${colors.border} rounded-lg p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex-1">
|
||||
<h4 class="font-medium ${colors.text} mb-1">
|
||||
${action.icon ? `<i class="${action.icon} mr-2"></i>` : ''}${action.title || action.id}
|
||||
</h4>
|
||||
<p class="text-sm ${colors.textLight}">${action.description || ''}</p>
|
||||
</div>
|
||||
<button type="button"
|
||||
id="${actionId}"
|
||||
onclick="executePluginAction('${action.id}', ${index}, '${safePluginId}')"
|
||||
data-plugin-id="${safePluginId}"
|
||||
data-action-id="${action.id}"
|
||||
class="btn ${colors.btn} text-white px-4 py-2 rounded-md whitespace-nowrap">
|
||||
${action.icon ? `<i class="${action.icon} mr-2"></i>` : ''}${action.button_text || action.title || 'Execute'}
|
||||
</button>
|
||||
</div>
|
||||
<div id="${statusId}" class="mt-3 hidden"></div>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
|
||||
formHtml += `
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
return formHtml;
|
||||
},
|
||||
|
||||
generateSimpleConfigForm(config, webUiActions = [], pluginId = '') {
|
||||
let actionsHtml = '';
|
||||
if (webUiActions && webUiActions.length > 0) {
|
||||
const colorMap = {
|
||||
'blue': { bg: 'bg-blue-50', border: 'border-blue-200', text: 'text-blue-900', textLight: 'text-blue-700', btn: 'bg-blue-600 hover:bg-blue-700' },
|
||||
'green': { bg: 'bg-green-50', border: 'border-green-200', text: 'text-green-900', textLight: 'text-green-700', btn: 'bg-green-600 hover:bg-green-700' },
|
||||
'red': { bg: 'bg-red-50', border: 'border-red-200', text: 'text-red-900', textLight: 'text-red-700', btn: 'bg-red-600 hover:bg-red-700' },
|
||||
'yellow': { bg: 'bg-yellow-50', border: 'border-yellow-200', text: 'text-yellow-900', textLight: 'text-yellow-700', btn: 'bg-yellow-600 hover:bg-yellow-700' },
|
||||
'purple': { bg: 'bg-purple-50', border: 'border-purple-200', text: 'text-purple-900', textLight: 'text-purple-700', btn: 'bg-purple-600 hover:bg-purple-700' }
|
||||
};
|
||||
|
||||
actionsHtml = `
|
||||
<div class="border-t border-gray-200 pt-4 mt-4">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-3">Actions</h3>
|
||||
<div class="space-y-3">
|
||||
`;
|
||||
webUiActions.forEach((action, index) => {
|
||||
const actionId = `action-${action.id}-${index}`;
|
||||
const statusId = `action-status-${action.id}-${index}`;
|
||||
const bgColor = action.color || 'blue';
|
||||
const colors = colorMap[bgColor] || colorMap['blue'];
|
||||
// Ensure pluginId is valid for template interpolation
|
||||
const safePluginId = pluginId || '';
|
||||
actionsHtml += `
|
||||
<div class="${colors.bg} border ${colors.border} rounded-lg p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex-1">
|
||||
<h4 class="font-medium ${colors.text} mb-1">
|
||||
${action.icon ? `<i class="${action.icon} mr-2"></i>` : ''}${action.title || action.id}
|
||||
</h4>
|
||||
<p class="text-sm ${colors.textLight}">${action.description || ''}</p>
|
||||
</div>
|
||||
<button type="button"
|
||||
id="${actionId}"
|
||||
onclick="executePluginAction('${action.id}', ${index}, '${safePluginId}')"
|
||||
data-plugin-id="${safePluginId}"
|
||||
data-action-id="${action.id}"
|
||||
class="btn ${colors.btn} text-white px-4 py-2 rounded-md">
|
||||
${action.icon ? `<i class="${action.icon} mr-2"></i>` : ''}${action.button_text || action.title || 'Execute'}
|
||||
</button>
|
||||
</div>
|
||||
<div id="${statusId}" class="mt-3 hidden"></div>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
actionsHtml += `
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
return `
|
||||
<div class="form-group">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Display Duration (seconds)</label>
|
||||
<input type="number" name="display_duration" value="${Math.max(5, Math.min(300, config.display_duration || 30))}" min="5" max="300" class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm">
|
||||
<p class="text-sm text-gray-600 mt-1">How long to show this plugin's content</p>
|
||||
</div>
|
||||
${actionsHtml}
|
||||
`;
|
||||
},
|
||||
|
||||
// Helper function to get schema property type for a field path
|
||||
getSchemaPropertyType(schema, path) {
|
||||
if (!schema || !schema.properties) return null;
|
||||
|
||||
@@ -1,133 +0,0 @@
|
||||
/**
|
||||
* Plugin configuration form management.
|
||||
*
|
||||
* Handles configuration form generation, validation, and submission.
|
||||
*/
|
||||
|
||||
const PluginConfigManager = {
|
||||
/**
|
||||
* Current plugin configuration state.
|
||||
*/
|
||||
currentState: {
|
||||
pluginId: null,
|
||||
config: null,
|
||||
schema: null,
|
||||
jsonEditor: null
|
||||
},
|
||||
|
||||
/**
|
||||
* Initialize configuration for a plugin.
|
||||
*
|
||||
* @param {string} pluginId - Plugin identifier
|
||||
* @returns {Promise<Object>} Configuration and schema
|
||||
*/
|
||||
async initialize(pluginId) {
|
||||
try {
|
||||
const [config, schema] = await Promise.all([
|
||||
window.PluginAPI.getPluginConfig(pluginId),
|
||||
window.PluginAPI.getPluginSchema(pluginId)
|
||||
]);
|
||||
|
||||
this.currentState = {
|
||||
pluginId,
|
||||
config,
|
||||
schema,
|
||||
jsonEditor: null
|
||||
};
|
||||
|
||||
return { config, schema };
|
||||
} catch (error) {
|
||||
if (window.errorHandler) {
|
||||
window.errorHandler.displayError(error, `Failed to initialize config for ${pluginId}`);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Reset configuration to defaults.
|
||||
*
|
||||
* @param {string} pluginId - Plugin identifier
|
||||
* @returns {Promise<Object>} Default configuration
|
||||
*/
|
||||
async resetToDefaults(pluginId) {
|
||||
try {
|
||||
const result = await window.PluginAPI.resetPluginConfig(pluginId);
|
||||
|
||||
// Reload configuration
|
||||
if (this.currentState.pluginId === pluginId) {
|
||||
await this.initialize(pluginId);
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
if (window.errorHandler) {
|
||||
window.errorHandler.displayError(error, `Failed to reset config for ${pluginId}`);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Save configuration.
|
||||
*
|
||||
* @param {string} pluginId - Plugin identifier
|
||||
* @param {Object} config - Configuration data
|
||||
* @returns {Promise<Object>} Save result
|
||||
*/
|
||||
async save(pluginId, config) {
|
||||
try {
|
||||
const result = await window.PluginAPI.savePluginConfig(pluginId, config);
|
||||
|
||||
// Update local state
|
||||
if (this.currentState.pluginId === pluginId) {
|
||||
this.currentState.config = config;
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
if (window.errorHandler) {
|
||||
window.errorHandler.displayError(error, `Failed to save config for ${pluginId}`);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Validate configuration against schema.
|
||||
*
|
||||
* @param {Object} config - Configuration data
|
||||
* @param {Object} schema - JSON schema
|
||||
* @returns {Object} Validation result with errors
|
||||
*/
|
||||
validate(config, schema) {
|
||||
// Basic validation - full validation happens on server
|
||||
const errors = [];
|
||||
|
||||
if (!schema || !schema.properties) {
|
||||
return { valid: true, errors: [] };
|
||||
}
|
||||
|
||||
// Check required fields
|
||||
if (schema.required) {
|
||||
for (const field of schema.required) {
|
||||
if (!(field in config)) {
|
||||
errors.push(`Required field '${field}' is missing`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
valid: errors.length === 0,
|
||||
errors: errors
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// Export
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = PluginConfigManager;
|
||||
} else {
|
||||
window.PluginConfigManager = PluginConfigManager;
|
||||
}
|
||||
|
||||
@@ -280,14 +280,40 @@ In your plugin's `config_schema.json`:
|
||||
}
|
||||
```
|
||||
|
||||
### Step 3: Widget Loading
|
||||
### Step 3: Declare the Widget in `manifest.json`
|
||||
|
||||
The widget will be automatically loaded when the plugin configuration form is rendered. The system will:
|
||||
The manifest is the allowlist -- a widget is served only if the plugin declares
|
||||
it, so shipping a file under `widgets/` does not by itself publish it:
|
||||
|
||||
1. Check if widget is registered in the core registry
|
||||
2. If not found, attempt to load from plugin directory: `/static/plugin-widgets/[plugin-id]/[widget-name].js`
|
||||
```json
|
||||
{
|
||||
"widgets": [
|
||||
{ "name": "my-custom-widget", "script": "my-custom-widget.js" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`script` is optional and defaults to `[name].js`. It must be a plain filename
|
||||
directly inside the plugin's `widgets/` directory.
|
||||
|
||||
### Step 4: Widget Loading
|
||||
|
||||
The widget is loaded on demand when the config form renders a field that
|
||||
references it. The system will:
|
||||
|
||||
1. Check if the widget is registered in the core registry
|
||||
2. If not, fetch `/static/plugin-widgets/[plugin-id]/[widget-name].js`, which
|
||||
serves the declared script from the plugin's `widgets/` directory
|
||||
3. Render the widget using the registered `render` function
|
||||
|
||||
The fetch is a dynamic `import()`, so the file must parse as an ES module (a
|
||||
plain IIFE does). If anything fails, the field falls back to a plain text input
|
||||
holding the current value, so a broken widget never costs the user their
|
||||
configured value.
|
||||
|
||||
Only `string`-typed fields take this path today; see `docs/widget-guide.md`
|
||||
for the full details and limitations.
|
||||
|
||||
## Widget API Reference
|
||||
|
||||
### Widget Definition Object
|
||||
|
||||
@@ -121,7 +121,9 @@
|
||||
family: family,
|
||||
display_name: info.display_name || generateDisplayName(info.filename || family),
|
||||
path: info.path,
|
||||
type: info.type || 'unknown'
|
||||
type: info.type || 'unknown',
|
||||
scalable: info.scalable,
|
||||
native_size: info.native_size
|
||||
}));
|
||||
} else if (Array.isArray(data)) {
|
||||
// Direct array format
|
||||
@@ -174,6 +176,12 @@
|
||||
const xOptions = config['x-options'] || config['x_options'] || {};
|
||||
const placeholder = xOptions.placeholder || 'Select a font...';
|
||||
const filterTypes = xOptions.filterTypes || null; // e.g., ['ttf', 'bdf']
|
||||
// Bitmap (BDF) fonts render at the one size baked into the file
|
||||
// and ignore the size setting entirely, so a field that caps size
|
||||
// at 16 can still be handed a 27px face. maxFixedSize hides the
|
||||
// ones that cannot honour the cap, rather than offering a choice
|
||||
// that silently overflows the panel.
|
||||
const maxFixedSize = Number(xOptions.maxFixedSize) || null;
|
||||
const showPreview = xOptions.showPreview === true;
|
||||
const disabled = xOptions.disabled === true;
|
||||
const required = xOptions.required === true;
|
||||
@@ -204,6 +212,16 @@
|
||||
return filterTypes.some(t => t.toLowerCase() === fontType);
|
||||
});
|
||||
}
|
||||
if (maxFixedSize) {
|
||||
filteredFonts = filteredFonts.filter(font => {
|
||||
// A scalable face can always meet the cap.
|
||||
if (font.scalable !== false) { return true; }
|
||||
// An unknown native size is not evidence it is too
|
||||
// big; keep it rather than hiding a usable font.
|
||||
if (!font.native_size) { return true; }
|
||||
return font.native_size <= maxFixedSize;
|
||||
});
|
||||
}
|
||||
|
||||
// Build select HTML
|
||||
let html = `<div id="${fieldId}_widget" class="font-selector-widget" data-field-id="${fieldId}">`;
|
||||
|
||||
@@ -0,0 +1,598 @@
|
||||
/**
|
||||
* Style Editor Widget
|
||||
*
|
||||
* One compact row per display element -- font, size, colour, alignment,
|
||||
* visibility, X/Y nudge, scale -- instead of the nested accordions the
|
||||
* generic object renderer produces. A realistic scoreboard declares seven
|
||||
* elements with layout offsets and three modes, which comes to 65 nested
|
||||
* sections and five levels of clicking to reach one per-mode font size.
|
||||
*
|
||||
* Three things about this widget are load-bearing:
|
||||
*
|
||||
* 1. It emits ordinary inputs with the same dotted names the generic
|
||||
* renderer would produce (`customization.score_text.font`,
|
||||
* `customization.score_text.text_color.0`, `customization.layout.
|
||||
* score_text.x_offset`, `customization.modes.live....`). The whole
|
||||
* save/validate/merge pipeline is therefore untouched: no hidden JSON
|
||||
* blob, no new server-side parsing.
|
||||
*
|
||||
* 2. Its columns come from the schema, not from a list in here. A column
|
||||
* appears when any element declares that field, so a plugin adding a
|
||||
* field to its schema gets a control without this file changing, and a
|
||||
* logo that declares only offsets and a scale gets no empty font cell.
|
||||
*
|
||||
* 3. Mode tabs edit `customization.modes.<mode>`, whose fields mean
|
||||
* "inherit" when blank. Blank must post as empty (-> null), never as 0,
|
||||
* or every mode would pin itself to the base the first time it was saved.
|
||||
*
|
||||
* @module StyleEditorWidget
|
||||
*/
|
||||
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
if (typeof window.LEDMatrixWidgets === 'undefined') {
|
||||
console.error('[StyleEditor] LEDMatrixWidgets registry not found. Load registry.js first.');
|
||||
return;
|
||||
}
|
||||
|
||||
var FONT_CACHE = null;
|
||||
var FONT_INFLIGHT = null;
|
||||
|
||||
/** The font catalog, fetched once per page. */
|
||||
function loadFonts() {
|
||||
if (FONT_CACHE) { return Promise.resolve(FONT_CACHE); }
|
||||
if (FONT_INFLIGHT) { return FONT_INFLIGHT; }
|
||||
FONT_INFLIGHT = fetch('/api/v3/fonts/catalog')
|
||||
.then(function (r) {
|
||||
if (!r.ok) { throw new Error('font catalog fetch failed: ' + r.status); }
|
||||
return r.json();
|
||||
})
|
||||
.then(function (payload) {
|
||||
var catalog = (payload && payload.data && payload.data.catalog) || {};
|
||||
FONT_CACHE = Object.keys(catalog).map(function (key) {
|
||||
var entry = catalog[key];
|
||||
return {
|
||||
filename: entry.filename,
|
||||
label: entry.display_name || entry.filename,
|
||||
scalable: entry.scalable !== false,
|
||||
nativeSize: entry.native_size || null
|
||||
};
|
||||
}).sort(function (a, b) { return a.label.localeCompare(b.label); });
|
||||
return FONT_CACHE;
|
||||
})
|
||||
.catch(function (e) {
|
||||
console.warn('[StyleEditor] could not load the font catalog', e);
|
||||
// Leave FONT_CACHE unset and clear FONT_INFLIGHT so the next
|
||||
// call retries instead of being stuck on an empty result.
|
||||
FONT_INFLIGHT = null;
|
||||
return [];
|
||||
});
|
||||
return FONT_INFLIGHT;
|
||||
}
|
||||
|
||||
function el(tag, attrs, children) {
|
||||
var node = document.createElement(tag);
|
||||
Object.keys(attrs || {}).forEach(function (k) {
|
||||
if (k === 'class') { node.className = attrs[k]; }
|
||||
else if (k === 'text') { node.textContent = attrs[k]; }
|
||||
else if (attrs[k] !== null && attrs[k] !== undefined) {
|
||||
node.setAttribute(k, attrs[k]);
|
||||
}
|
||||
});
|
||||
(children || []).forEach(function (c) { node.appendChild(c); });
|
||||
return node;
|
||||
}
|
||||
|
||||
/** Walk a nested value object by path segments. */
|
||||
function at(value, path) {
|
||||
var cur = value;
|
||||
for (var i = 0; i < path.length; i++) {
|
||||
if (cur === null || typeof cur !== 'object') { return undefined; }
|
||||
cur = cur[path[i]];
|
||||
}
|
||||
return cur;
|
||||
}
|
||||
|
||||
/**
|
||||
* What a control should show: the configured value if there is one,
|
||||
* else the schema default.
|
||||
*
|
||||
* The base tab must fall back to the default rather than leaving the
|
||||
* control empty. An empty <select> shows its first option, which for
|
||||
* fonts is whatever sorts first alphabetically -- so an untouched
|
||||
* scoreboard claimed every element used 10x20.bdf, and the size control
|
||||
* then locked itself to that bitmap font's fixed size. A mode tab is the
|
||||
* opposite: blank there means inherit, so it must stay blank.
|
||||
*/
|
||||
function effective(value, path, prop, optional) {
|
||||
var v = at(value, path);
|
||||
if (v !== undefined && v !== null) { return v; }
|
||||
if (optional) { return undefined; }
|
||||
return prop ? prop.default : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Element blocks in a customization schema, in declared order.
|
||||
* `layout` and `modes` are containers, not elements.
|
||||
*/
|
||||
function elementKeys(schema) {
|
||||
var props = (schema && schema.properties) || {};
|
||||
var order = schema['x-propertyOrder'] || Object.keys(props);
|
||||
return order.filter(function (k) {
|
||||
return k !== 'layout' && k !== 'modes'
|
||||
&& props[k] && props[k].properties;
|
||||
});
|
||||
}
|
||||
|
||||
function titleOf(schema, key) {
|
||||
var prop = (schema.properties || {})[key] || {};
|
||||
return prop.title || key.replace(/_/g, ' ');
|
||||
}
|
||||
|
||||
// ---- individual controls -------------------------------------------
|
||||
|
||||
/**
|
||||
* Fonts this field can actually honour.
|
||||
*
|
||||
* A bitmap (BDF) face renders at the one size baked into the file and
|
||||
* ignores the size setting, so an element that caps size at 16 can
|
||||
* still be handed a 27px face and overflow a 32px panel. The cap comes
|
||||
* from the element's own font_size.maximum, so this hides exactly the
|
||||
* faces that cannot meet the limit the plugin already declared --
|
||||
* rather than a list curated by hand, which is what these fields used
|
||||
* to carry and why an uploaded font could never appear in one.
|
||||
*/
|
||||
function usableFonts(fonts, maxFixedSize, current) {
|
||||
if (!maxFixedSize) { return fonts; }
|
||||
return fonts.filter(function (f) {
|
||||
// Keep the font already saved on this element even if it no
|
||||
// longer fits the cap -- dropping it would leave the select
|
||||
// with nothing chosen and silently misrepresent the config.
|
||||
if (current && f.filename === current) { return true; }
|
||||
if (f.scalable !== false) { return true; }
|
||||
// An unknown native size is not evidence it is too big.
|
||||
if (!f.nativeSize) { return true; }
|
||||
return f.nativeSize <= maxFixedSize;
|
||||
});
|
||||
}
|
||||
|
||||
function fontControl(name, current, fonts, optional, onChange) {
|
||||
var select = el('select', {
|
||||
name: name,
|
||||
class: 'form-control text-sm style-editor-font'
|
||||
});
|
||||
if (optional) {
|
||||
select.appendChild(el('option', { value: '', text: 'Inherit' }));
|
||||
}
|
||||
fonts.forEach(function (f) {
|
||||
var opt = el('option', { value: f.filename, text: f.label });
|
||||
opt.dataset.scalable = f.scalable ? '1' : '0';
|
||||
opt.dataset.nativeSize = f.nativeSize || '';
|
||||
select.appendChild(opt);
|
||||
});
|
||||
if (current) { select.value = current; }
|
||||
if (onChange) { select.addEventListener('change', onChange); }
|
||||
return select;
|
||||
}
|
||||
|
||||
function enumControl(name, current, choices, optional) {
|
||||
var select = el('select', {
|
||||
name: name,
|
||||
class: 'form-control text-sm style-editor-enum'
|
||||
});
|
||||
if (optional) {
|
||||
select.appendChild(el('option', { value: '', text: 'Inherit' }));
|
||||
}
|
||||
choices.forEach(function (c) {
|
||||
select.appendChild(el('option', {
|
||||
value: c,
|
||||
text: String(c).replace(/_/g, ' ')
|
||||
}));
|
||||
});
|
||||
if (current !== undefined && current !== null) { select.value = current; }
|
||||
return select;
|
||||
}
|
||||
|
||||
function booleanControl(name, current, optional) {
|
||||
if (optional) {
|
||||
// Three states, not two: on, off, and "follow the base". A bare
|
||||
// checkbox cannot say the third, and an unchecked box would read
|
||||
// as "hide this in live mode" rather than "no preference".
|
||||
return enumControl(name, boolAsString(current),
|
||||
['true', 'false'], true);
|
||||
}
|
||||
// An unchecked checkbox posts nothing at all, so the hidden field
|
||||
// carries the value and the checkbox drives it. The save path turns
|
||||
// "true"/"false" into a real boolean.
|
||||
var wrap = el('div', { class: 'flex items-center' });
|
||||
var hidden = el('input', { type: 'hidden', name: name });
|
||||
var box = el('input', { type: 'checkbox', class: 'style-editor-check' });
|
||||
box.checked = current !== false;
|
||||
hidden.value = box.checked ? 'true' : 'false';
|
||||
box.addEventListener('change', function () {
|
||||
hidden.value = box.checked ? 'true' : 'false';
|
||||
});
|
||||
wrap.appendChild(box);
|
||||
wrap.appendChild(hidden);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function boolAsString(value) {
|
||||
if (value === true) { return 'true'; }
|
||||
if (value === false) { return 'false'; }
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function numberControl(name, current, prop, placeholder) {
|
||||
var input = el('input', {
|
||||
type: 'number',
|
||||
name: name,
|
||||
class: 'form-control text-sm style-editor-size',
|
||||
placeholder: placeholder || ''
|
||||
});
|
||||
if (prop && prop.minimum !== undefined) { input.min = prop.minimum; }
|
||||
if (prop && prop.maximum !== undefined) { input.max = prop.maximum; }
|
||||
if (prop && prop.type
|
||||
&& String(prop.type).indexOf('number') !== -1) {
|
||||
input.step = 'any';
|
||||
}
|
||||
// Blank stays blank. For a mode field that is the inherit sentinel;
|
||||
// writing 0 here would pin the mode to the base on the next save.
|
||||
if (current !== undefined && current !== null) { input.value = current; }
|
||||
return input;
|
||||
}
|
||||
|
||||
function toHex(rgb) {
|
||||
if (!Array.isArray(rgb) || rgb.length < 3) { return '#ffffff'; }
|
||||
return '#' + rgb.slice(0, 3).map(function (c) {
|
||||
var v = Math.max(0, Math.min(255, parseInt(c, 10) || 0));
|
||||
return ('0' + v.toString(16)).slice(-2);
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function colourControl(name, current, optional) {
|
||||
// Three number inputs carry the value (name.0/.1/.2 is what the save
|
||||
// path recombines); the colour swatch is the human control and just
|
||||
// drives them.
|
||||
var wrap = el('div', { class: 'flex items-center gap-1' });
|
||||
var has = Array.isArray(current) && current.length >= 3;
|
||||
var channels = [0, 1, 2].map(function (i) {
|
||||
var input = el('input', { type: 'hidden', name: name + '.' + i });
|
||||
if (has) { input.value = current[i]; }
|
||||
return input;
|
||||
});
|
||||
|
||||
// A disabled input is not submitted at all, which is how an unset
|
||||
// optional colour says nothing rather than posting three empty
|
||||
// strings. The server copes with those too, but not posting them is
|
||||
// both clearer and one less thing depending on that.
|
||||
function setSubmitted(on) {
|
||||
channels.forEach(function (c) { c.disabled = !on; });
|
||||
}
|
||||
setSubmitted(!optional || has);
|
||||
|
||||
var clearBtn = null;
|
||||
var swatch = el('input', {
|
||||
type: 'color',
|
||||
class: 'style-editor-colour',
|
||||
value: has ? toHex(current) : '#ffffff'
|
||||
});
|
||||
swatch.addEventListener('input', function () {
|
||||
var hex = swatch.value;
|
||||
[1, 3, 5].forEach(function (start, i) {
|
||||
channels[i].value = parseInt(hex.substr(start, 2), 16);
|
||||
});
|
||||
setSubmitted(true);
|
||||
if (clearBtn) { clearBtn.classList.remove('hidden'); }
|
||||
});
|
||||
wrap.appendChild(swatch);
|
||||
channels.forEach(function (c) { wrap.appendChild(c); });
|
||||
|
||||
if (optional) {
|
||||
// A mode colour must be able to go back to "inherit", which means
|
||||
// posting nothing at all -- an <input type=color> has no empty
|
||||
// state of its own.
|
||||
clearBtn = el('button', {
|
||||
type: 'button',
|
||||
class: 'text-xs text-gray-500 hover:text-gray-800 style-editor-clear',
|
||||
title: 'Inherit the colour above',
|
||||
text: '×'
|
||||
});
|
||||
if (!has) { clearBtn.classList.add('hidden'); }
|
||||
clearBtn.addEventListener('click', function () {
|
||||
channels.forEach(function (c) { c.value = ''; });
|
||||
setSubmitted(false);
|
||||
swatch.value = '#ffffff';
|
||||
clearBtn.classList.add('hidden');
|
||||
});
|
||||
wrap.appendChild(clearBtn);
|
||||
}
|
||||
return wrap;
|
||||
}
|
||||
|
||||
// ---- columns ---------------------------------------------------------
|
||||
|
||||
var COLUMN_ORDER = ['font', 'font_size', 'text_color', 'align', 'visible',
|
||||
'x_offset', 'y_offset', 'scale'];
|
||||
var COLUMN_LABELS = {
|
||||
font: 'Font', font_size: 'Size', text_color: 'Colour',
|
||||
align: 'Align', visible: 'Show',
|
||||
x_offset: 'X', y_offset: 'Y', scale: 'Scale'
|
||||
};
|
||||
var COLUMN_WIDTHS = {
|
||||
font: 'minmax(8rem, 2fr)', text_color: '4.5rem', align: '6rem',
|
||||
visible: '3.5rem'
|
||||
};
|
||||
|
||||
/**
|
||||
* The columns a table needs, derived from the schema rather than fixed.
|
||||
*
|
||||
* Two sources: the sub-fields elements declare (font, font_size,
|
||||
* text_color, visible, align) and the sub-fields their layout blocks
|
||||
* declare (x_offset, y_offset, scale).
|
||||
*/
|
||||
function columnsFor(schema) {
|
||||
var props = schema.properties || {};
|
||||
var layoutProps = (props.layout || {}).properties || {};
|
||||
var seen = {};
|
||||
elementKeys(schema).forEach(function (key) {
|
||||
Object.keys((props[key] || {}).properties || {}).forEach(
|
||||
function (f) { seen[f] = 'element'; });
|
||||
Object.keys((layoutProps[key] || {}).properties || {}).forEach(
|
||||
function (f) { seen[f] = 'layout'; });
|
||||
});
|
||||
var known = COLUMN_ORDER.filter(function (f) { return seen[f]; });
|
||||
// Anything the schema declares that this file has never heard of
|
||||
// still gets a column, rather than silently vanishing.
|
||||
var extra = Object.keys(seen).filter(function (f) {
|
||||
return COLUMN_ORDER.indexOf(f) === -1;
|
||||
}).sort();
|
||||
return known.concat(extra).map(function (f) {
|
||||
return {
|
||||
key: f,
|
||||
where: seen[f],
|
||||
label: COLUMN_LABELS[f] || f.replace(/_/g, ' ')
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** Build the control for one cell from its schema property. */
|
||||
function control(opts) {
|
||||
var prop = opts.prop;
|
||||
var declared = prop.type;
|
||||
var types = Array.isArray(declared) ? declared : [declared];
|
||||
|
||||
if (opts.key === 'font' || prop['x-widget'] === 'font-selector') {
|
||||
var xOptions = prop['x-options'] || prop['x_options'] || {};
|
||||
var cap = Number(xOptions.maxFixedSize) || opts.maxFixedSize || null;
|
||||
return fontControl(opts.name, opts.current,
|
||||
usableFonts(opts.fonts, cap, opts.current),
|
||||
opts.optional, opts.onFontChange);
|
||||
}
|
||||
if (types.indexOf('boolean') !== -1) {
|
||||
return booleanControl(opts.name, opts.current, opts.optional);
|
||||
}
|
||||
if (types.indexOf('array') !== -1) {
|
||||
return colourControl(opts.name, opts.current, opts.optional);
|
||||
}
|
||||
if (Array.isArray(prop.enum)) {
|
||||
return enumControl(opts.name, opts.current, prop.enum,
|
||||
opts.optional);
|
||||
}
|
||||
return numberControl(opts.name, opts.current, prop,
|
||||
opts.optional ? 'inherit' : '');
|
||||
}
|
||||
|
||||
function elementRow(opts) {
|
||||
var schema = opts.schema;
|
||||
var key = opts.key;
|
||||
var value = opts.value;
|
||||
var optional = opts.optional;
|
||||
|
||||
var props = ((schema.properties || {})[key] || {}).properties || {};
|
||||
var layoutProps = ((schema.properties || {}).layout || {}).properties || {};
|
||||
var axes = (layoutProps[key] || {}).properties || {};
|
||||
|
||||
var row = el('div', {
|
||||
class: 'style-editor-row grid items-center gap-2 py-1',
|
||||
'data-element': key
|
||||
});
|
||||
row.appendChild(el('div', {
|
||||
class: 'text-sm text-gray-700 style-editor-label',
|
||||
text: titleOf(schema, key)
|
||||
}));
|
||||
|
||||
var sizeInput = null;
|
||||
var sizeNote = el('span', { class: 'text-xs text-gray-500 ml-1' });
|
||||
var fontSelect = null;
|
||||
|
||||
function syncSize(select) {
|
||||
// A BDF font has exactly one usable pixel size; offering a free
|
||||
// number there offers something that cannot take effect.
|
||||
if (!sizeInput || !select) { return; }
|
||||
var opt = select.options[select.selectedIndex];
|
||||
var scalable = !opt || opt.dataset.scalable !== '0';
|
||||
if (scalable) {
|
||||
sizeInput.disabled = false;
|
||||
sizeInput.title = '';
|
||||
sizeNote.textContent = '';
|
||||
} else {
|
||||
sizeInput.disabled = true;
|
||||
sizeInput.value = opt.dataset.nativeSize || '';
|
||||
sizeInput.title = 'This is a bitmap font; it renders at one fixed size.';
|
||||
sizeNote.textContent = 'fixed';
|
||||
}
|
||||
}
|
||||
|
||||
opts.columns.forEach(function (col) {
|
||||
var inLayout = col.where === 'layout';
|
||||
var prop = inLayout ? axes[col.key] : props[col.key];
|
||||
if (!prop) {
|
||||
// This element does not declare that field; keep the grid
|
||||
// aligned with an empty cell.
|
||||
row.appendChild(el('span'));
|
||||
return;
|
||||
}
|
||||
var path = inLayout ? ['layout', key, col.key] : [key, col.key];
|
||||
var base = inLayout ? opts.layoutPrefix + '.' + key
|
||||
: opts.prefix + '.' + key;
|
||||
var node = control({
|
||||
key: col.key,
|
||||
prop: prop,
|
||||
name: base + '.' + col.key,
|
||||
current: effective(value, path, prop, optional),
|
||||
optional: optional,
|
||||
fonts: opts.fonts,
|
||||
// The element's declared size ceiling is what a fixed-size
|
||||
// font has to fit under.
|
||||
maxFixedSize: (props.font_size || {}).maximum || null,
|
||||
onFontChange: function () { syncSize(fontSelect); }
|
||||
});
|
||||
if (col.key === 'font') { fontSelect = node; }
|
||||
if (col.key === 'font_size') {
|
||||
sizeInput = node;
|
||||
var cell = el('div', { class: 'flex items-center' });
|
||||
cell.appendChild(node);
|
||||
cell.appendChild(sizeNote);
|
||||
node = cell;
|
||||
}
|
||||
row.appendChild(node);
|
||||
});
|
||||
|
||||
if (fontSelect) { syncSize(fontSelect); }
|
||||
return row;
|
||||
}
|
||||
|
||||
function header(columns) {
|
||||
var row = el('div', {
|
||||
class: 'style-editor-row style-editor-head grid gap-2 pb-1 mb-1 border-b border-gray-300'
|
||||
});
|
||||
['Element'].concat(columns.map(function (c) { return c.label; }))
|
||||
.forEach(function (label) {
|
||||
row.appendChild(el('div', {
|
||||
class: 'text-xs font-semibold text-gray-500 uppercase',
|
||||
text: label
|
||||
}));
|
||||
});
|
||||
return row;
|
||||
}
|
||||
|
||||
function table(opts) {
|
||||
var wrap = el('div', { class: 'style-editor-table' });
|
||||
var columns = columnsFor(opts.schema);
|
||||
// Sized here rather than in CSS: the column count depends on what
|
||||
// the plugin declared.
|
||||
wrap.style.gridTemplateColumns = '';
|
||||
wrap.style.setProperty('--style-editor-columns',
|
||||
'minmax(7rem, 1.4fr) ' + columns.map(function (c) {
|
||||
return COLUMN_WIDTHS[c.key] || '5rem';
|
||||
}).join(' '));
|
||||
wrap.appendChild(header(columns));
|
||||
elementKeys(opts.schema).forEach(function (key) {
|
||||
wrap.appendChild(elementRow({
|
||||
schema: opts.schema,
|
||||
key: key,
|
||||
columns: columns,
|
||||
prefix: opts.prefix,
|
||||
layoutPrefix: opts.prefix + '.layout',
|
||||
value: opts.value,
|
||||
fonts: opts.fonts,
|
||||
optional: opts.optional
|
||||
}));
|
||||
});
|
||||
return wrap;
|
||||
}
|
||||
|
||||
// ---- widget ----------------------------------------------------------
|
||||
|
||||
window.LEDMatrixWidgets.register('style-editor', {
|
||||
name: 'Style Editor',
|
||||
version: '1.1.0',
|
||||
|
||||
render: function (container, config, value, options) {
|
||||
var schema = (config && config.schema) || {};
|
||||
var base = (options && options.name) || 'customization';
|
||||
var current = value || {};
|
||||
|
||||
container.innerHTML = '';
|
||||
var root = el('div', { class: 'style-editor' });
|
||||
container.appendChild(root);
|
||||
|
||||
loadFonts().then(function (fonts) {
|
||||
var modeProps = ((schema.properties || {}).modes || {}).properties || {};
|
||||
var modes = Object.keys(modeProps);
|
||||
|
||||
var panels = {};
|
||||
var tabs = null;
|
||||
if (modes.length) {
|
||||
tabs = el('div', { class: 'style-editor-tabs flex gap-1 mb-2' });
|
||||
root.appendChild(tabs);
|
||||
}
|
||||
|
||||
function panel(id, node) {
|
||||
panels[id] = node;
|
||||
node.classList.add('style-editor-panel');
|
||||
root.appendChild(node);
|
||||
}
|
||||
|
||||
function show(id) {
|
||||
Object.keys(panels).forEach(function (k) {
|
||||
panels[k].hidden = (k !== id);
|
||||
});
|
||||
if (!tabs) { return; }
|
||||
Array.prototype.forEach.call(tabs.children, function (b) {
|
||||
b.classList.toggle('is-active', b.dataset.panel === id);
|
||||
});
|
||||
}
|
||||
|
||||
function tab(id, label) {
|
||||
if (!tabs) { return; }
|
||||
var b = el('button', {
|
||||
type: 'button',
|
||||
class: 'style-editor-tab text-sm px-3 py-1 rounded',
|
||||
text: label
|
||||
});
|
||||
b.dataset.panel = id;
|
||||
b.addEventListener('click', function () { show(id); });
|
||||
tabs.appendChild(b);
|
||||
}
|
||||
|
||||
panel('__base__', table({
|
||||
schema: schema, prefix: base, value: current,
|
||||
fonts: fonts, optional: false
|
||||
}));
|
||||
tab('__base__', 'All modes');
|
||||
|
||||
modes.forEach(function (mode) {
|
||||
var modeSchema = modeProps[mode] || {};
|
||||
var modeValue = at(current, ['modes', mode]) || {};
|
||||
var node = el('div');
|
||||
node.appendChild(el('p', {
|
||||
class: 'text-xs text-gray-500 mb-2',
|
||||
text: 'Anything left blank follows the "All modes" tab.'
|
||||
}));
|
||||
node.appendChild(table({
|
||||
schema: modeSchema,
|
||||
prefix: base + '.modes.' + mode,
|
||||
value: modeValue,
|
||||
fonts: fonts,
|
||||
optional: true
|
||||
}));
|
||||
panel(mode, node);
|
||||
tab(mode, modeSchema.title || mode);
|
||||
});
|
||||
|
||||
show('__base__');
|
||||
});
|
||||
},
|
||||
|
||||
getValue: function () {
|
||||
// The inputs are ordinary named form fields; the form itself is
|
||||
// the source of truth, so there is no separate value to hand back.
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
console.log('[StyleEditor] widget registered');
|
||||
})();
|
||||
@@ -972,7 +972,6 @@
|
||||
<!-- Load plugin management modules -->
|
||||
<script src="{{ url_for('static', filename='v3/js/plugins/store_manager.js') }}" defer></script>
|
||||
<script src="{{ url_for('static', filename='v3/js/plugins/state_manager.js') }}" defer></script>
|
||||
<script src="{{ url_for('static', filename='v3/js/plugins/config_manager.js') }}" defer></script>
|
||||
<script src="{{ url_for('static', filename='v3/js/plugins/install_manager.js') }}" defer></script>
|
||||
<!-- Shared search/filter/sort controller for the plugin card grids -->
|
||||
<script src="{{ url_for('static', filename='v3/js/plugins/list_filter.js') }}" defer></script>
|
||||
@@ -1003,6 +1002,7 @@
|
||||
<script src="{{ url_for('static', filename='v3/js/widgets/textarea.js') }}" defer></script>
|
||||
<script src="{{ url_for('static', filename='v3/js/widgets/select-dropdown.js') }}" defer></script>
|
||||
<script src="{{ url_for('static', filename='v3/js/widgets/font-selector.js') }}" defer></script>
|
||||
<script src="{{ url_for('static', filename='v3/js/widgets/style-editor.js') }}" defer></script>
|
||||
<script src="{{ url_for('static', filename='v3/js/widgets/toggle-switch.js') }}" defer></script>
|
||||
<script src="{{ url_for('static', filename='v3/js/widgets/radio-group.js') }}" defer></script>
|
||||
<script src="{{ url_for('static', filename='v3/js/widgets/date-picker.js') }}" defer></script>
|
||||
|
||||
@@ -1,20 +1,11 @@
|
||||
<div class="bg-white rounded-lg shadow p-6">
|
||||
<div class="border-b border-gray-200 pb-4 mb-6">
|
||||
<h2 class="text-lg font-semibold text-gray-900">Font Management</h2>
|
||||
<p class="mt-1 text-sm text-gray-600">Manage custom fonts, overrides, and system font configuration for your LED matrix display.</p>
|
||||
<p class="mt-1 text-sm text-gray-600">Upload, preview and manage the fonts available to every plugin.</p>
|
||||
</div>
|
||||
|
||||
<!-- Font System Overview -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-2 2xl:grid-cols-2 gap-6 mb-8">
|
||||
<!-- Detected Fonts from Managers -->
|
||||
<div class="bg-gray-50 rounded-lg p-4">
|
||||
<h3 class="text-md font-medium text-gray-900 mb-3">Detected Manager Fonts</h3>
|
||||
<div id="detected-fonts" class="bg-gray-800 text-gray-100 font-mono text-sm p-3 rounded h-40 overflow-y-auto">
|
||||
<div class="text-gray-400">Loading...</div>
|
||||
</div>
|
||||
<p class="text-sm text-gray-600 mt-2">Fonts currently in use by managers (auto-detected)</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-8">
|
||||
<!-- 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>
|
||||
@@ -78,73 +69,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Font Overrides -->
|
||||
<div class="bg-gray-50 rounded-lg p-4 mb-8">
|
||||
<h3 class="text-md font-medium text-gray-900 mb-4">Element Font Overrides</h3>
|
||||
<p class="text-sm text-gray-600 mb-4">Override fonts for specific display elements. Changes take effect immediately.</p>
|
||||
|
||||
<!-- Add New Override -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 xl:grid-cols-4 2xl:grid-cols-4 gap-4 mb-4 p-4 bg-white rounded border">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Element</label>
|
||||
<select id="override-element" class="form-control text-sm">
|
||||
<option value="">Select an element...</option>
|
||||
<optgroup label="Sports">
|
||||
<option value="nfl.live.score">NFL Live Score</option>
|
||||
<option value="nfl.live.time">NFL Live Time</option>
|
||||
<option value="nfl.live.team">NFL Live Team</option>
|
||||
<option value="mlb.live.score">MLB Live Score</option>
|
||||
<option value="nhl.live.score">NHL Live Score</option>
|
||||
<option value="nba.live.score">NBA Live Score</option>
|
||||
</optgroup>
|
||||
<optgroup label="Clock">
|
||||
<option value="clock.time">Clock Time</option>
|
||||
<option value="clock.date">Clock Date</option>
|
||||
</optgroup>
|
||||
<optgroup label="Weather">
|
||||
<option value="weather.current">Weather Current</option>
|
||||
<option value="weather.forecast">Weather Forecast</option>
|
||||
</optgroup>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Font Family</label>
|
||||
<select id="override-family" class="form-control text-sm">
|
||||
<option value="">Use default</option>
|
||||
<!-- Dynamically populated from font catalog -->
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Font Size</label>
|
||||
<select id="override-size" class="form-control text-sm">
|
||||
<option value="">Use default</option>
|
||||
<option value="xs">Extra Small (6px)</option>
|
||||
<option value="sm">Small (8px)</option>
|
||||
<option value="md">Medium (10px)</option>
|
||||
<option value="lg">Large (12px)</option>
|
||||
<option value="xl">Extra Large (14px)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="flex items-end">
|
||||
<button id="add-override-btn" class="btn bg-blue-600 hover:bg-blue-700 text-white px-4 py-2">
|
||||
<i class="fas fa-plus mr-2"></i>Add Override
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Current Overrides List -->
|
||||
<div id="overrides-container">
|
||||
<h4 class="text-sm font-medium text-gray-900 mb-3">Current Overrides</h4>
|
||||
<div id="overrides-list" class="space-y-2">
|
||||
<!-- Overrides will be populated here -->
|
||||
<div class="text-gray-500 text-sm italic">No font overrides configured</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Font Preview -->
|
||||
<div class="bg-gray-50 rounded-lg p-4">
|
||||
<h3 class="text-md font-medium text-gray-900 mb-4">Font Preview</h3>
|
||||
@@ -206,13 +130,11 @@
|
||||
// Initialize global variables on window object
|
||||
window.fontCatalog = window.fontCatalog || {};
|
||||
window.fontTokens = window.fontTokens || {};
|
||||
window.fontOverrides = window.fontOverrides || {};
|
||||
window.selectedFontFiles = window.selectedFontFiles || [];
|
||||
|
||||
// Create references that can be reassigned
|
||||
var fontCatalog = window.fontCatalog;
|
||||
var fontTokens = window.fontTokens;
|
||||
var fontOverrides = window.fontOverrides;
|
||||
var selectedFontFiles = window.selectedFontFiles;
|
||||
|
||||
// Retry counter for initialization
|
||||
@@ -222,17 +144,15 @@
|
||||
function initializeFontsTab() {
|
||||
// Allow re-initialization on each HTMX content swap
|
||||
// The window._fontsScriptLoaded guard prevents function redeclaration
|
||||
const detectedEl = document.getElementById('detected-fonts');
|
||||
const availableEl = document.getElementById('available-fonts');
|
||||
|
||||
if (!detectedEl || !availableEl) {
|
||||
if (!availableEl) {
|
||||
initRetryCount++;
|
||||
if (initRetryCount >= MAX_INIT_RETRIES) {
|
||||
console.error('Fonts tab elements not found after max retries, giving up');
|
||||
return;
|
||||
}
|
||||
console.log('Fonts tab elements not found, retrying...', {
|
||||
detectedFonts: !!detectedEl,
|
||||
availableFonts: !!availableEl,
|
||||
attempt: initRetryCount
|
||||
});
|
||||
@@ -259,7 +179,6 @@ function initializeFontsTab() {
|
||||
const fontFileInput = document.getElementById('font-file-input');
|
||||
const uploadFontsBtn = document.getElementById('upload-fonts-btn');
|
||||
const cancelUploadBtn = document.getElementById('cancel-upload-btn');
|
||||
const addOverrideBtn = document.getElementById('add-override-btn');
|
||||
const updatePreviewBtn = document.getElementById('update-preview-btn');
|
||||
|
||||
if (uploadDropzone && fontFileInput) {
|
||||
@@ -280,9 +199,6 @@ function initializeFontsTab() {
|
||||
cancelUploadBtn.addEventListener('click', cancelFontUpload);
|
||||
}
|
||||
|
||||
if (addOverrideBtn) {
|
||||
addOverrideBtn.addEventListener('click', addFontOverride);
|
||||
}
|
||||
|
||||
if (updatePreviewBtn) {
|
||||
updatePreviewBtn.addEventListener('click', updateFontPreview);
|
||||
@@ -319,9 +235,8 @@ window.initializeFontsTab = initializeFontsTab;
|
||||
// Function to initialize when fonts content is loaded
|
||||
function tryInitializeFontsTab() {
|
||||
const fontsContent = document.getElementById('fonts-content');
|
||||
const detectedFonts = document.getElementById('detected-fonts');
|
||||
|
||||
if (fontsContent && detectedFonts) {
|
||||
if (fontsContent) {
|
||||
console.log('Fonts content detected, initializing...');
|
||||
setTimeout(() => {
|
||||
initializeFontsTab();
|
||||
@@ -355,7 +270,6 @@ async function initializeFontManagement() {
|
||||
try {
|
||||
await loadFontData();
|
||||
populateFontSelects();
|
||||
displayCurrentOverrides();
|
||||
updateFontPreview();
|
||||
initializeFontUpload();
|
||||
} catch (error) {
|
||||
@@ -365,44 +279,39 @@ async function initializeFontManagement() {
|
||||
}
|
||||
|
||||
async function loadFontData() {
|
||||
const detectedContainer = document.getElementById('detected-fonts');
|
||||
const availableContainer = document.getElementById('available-fonts');
|
||||
|
||||
// Ensure containers exist before proceeding
|
||||
if (!detectedContainer || !availableContainer) {
|
||||
if (!availableContainer) {
|
||||
console.error('Font containers not found, cannot load font data');
|
||||
return;
|
||||
}
|
||||
|
||||
// Show loading states
|
||||
detectedContainer.innerHTML = '<div class="text-blue-400">Loading font data...</div>';
|
||||
availableContainer.innerHTML = '<div class="text-blue-400">Loading font data...</div>';
|
||||
|
||||
try {
|
||||
// Use absolute URLs to ensure they work when loaded via HTMX
|
||||
const [catalogRes, tokensRes, overridesRes] = await Promise.all([
|
||||
const [catalogRes, tokensRes] = await Promise.all([
|
||||
fetch(`/api/v3/fonts/catalog`),
|
||||
fetch(`/api/v3/fonts/tokens`),
|
||||
fetch(`/api/v3/fonts/overrides`)
|
||||
fetch(`/api/v3/fonts/tokens`)
|
||||
]);
|
||||
|
||||
// Check if all responses are successful
|
||||
if (!catalogRes.ok || !tokensRes.ok || !overridesRes.ok) {
|
||||
const statusText = `HTTP ${catalogRes.status}/${tokensRes.status}/${overridesRes.status}`;
|
||||
if (!catalogRes.ok || !tokensRes.ok) {
|
||||
const statusText = `HTTP ${catalogRes.status}/${tokensRes.status}`;
|
||||
console.error('Font API error:', statusText);
|
||||
throw new Error(`Failed to load font data: ${statusText}`);
|
||||
}
|
||||
|
||||
const catalogData = await catalogRes.json();
|
||||
const tokensData = await tokensRes.json();
|
||||
const overridesData = await overridesRes.json();
|
||||
|
||||
// Validate response structure
|
||||
if (!catalogData || !catalogData.data || !tokensData || !tokensData.data || !overridesData || !overridesData.data) {
|
||||
if (!catalogData || !catalogData.data || !tokensData || !tokensData.data) {
|
||||
console.error('Invalid font API response structure:', {
|
||||
catalog: !!catalogData?.data,
|
||||
tokens: !!tokensData?.data,
|
||||
overrides: !!overridesData?.data
|
||||
tokens: !!tokensData?.data
|
||||
});
|
||||
throw new Error('Invalid response format from font API');
|
||||
}
|
||||
@@ -410,27 +319,22 @@ async function loadFontData() {
|
||||
// Update both window properties and local references
|
||||
window.fontCatalog = catalogData.data.catalog || {};
|
||||
window.fontTokens = tokensData.data.tokens || {};
|
||||
window.fontOverrides = overridesData.data.overrides || {};
|
||||
|
||||
// Update local variable references
|
||||
fontCatalog = window.fontCatalog;
|
||||
fontTokens = window.fontTokens;
|
||||
fontOverrides = window.fontOverrides;
|
||||
|
||||
// Update displays
|
||||
updateDetectedFontsDisplay();
|
||||
updateAvailableFontsDisplay();
|
||||
|
||||
console.log('Font data loaded successfully', {
|
||||
catalogSize: Object.keys(fontCatalog).length,
|
||||
tokensSize: Object.keys(fontTokens).length,
|
||||
overridesSize: Object.keys(fontOverrides).length
|
||||
tokensSize: Object.keys(fontTokens).length
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error loading font data:', error);
|
||||
|
||||
// Show error states
|
||||
detectedContainer.innerHTML = '<div class="text-red-400">Error loading font data. Please refresh the page.</div>';
|
||||
availableContainer.innerHTML = '<div class="text-red-400">Error loading font data. Please refresh the page.</div>';
|
||||
|
||||
// Only show notification if showNotification is available
|
||||
@@ -444,52 +348,6 @@ async function loadFontData() {
|
||||
}
|
||||
}
|
||||
|
||||
function updateDetectedFontsDisplay() {
|
||||
const container = document.getElementById('detected-fonts');
|
||||
if (!container) return;
|
||||
|
||||
// In a real implementation, this would collect font usage from all active managers
|
||||
// For now, we'll simulate this by analyzing the font overrides and catalog
|
||||
const detectedFonts = {};
|
||||
|
||||
// Check font overrides for active elements
|
||||
for (const [elementKey, override] of Object.entries(fontOverrides)) {
|
||||
if (override.family) {
|
||||
detectedFonts[elementKey] = {
|
||||
family: override.family,
|
||||
size_px: override.size_px || 8,
|
||||
usage_count: 1, // Would be actual usage count in real implementation
|
||||
source: 'override'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Check font catalog for commonly used fonts
|
||||
for (const [fontKey, fontPath] of Object.entries(fontCatalog)) {
|
||||
// Add some commonly used system fonts if not already in overrides
|
||||
if (!detectedFonts[fontKey]) {
|
||||
detectedFonts[fontKey] = {
|
||||
family: fontKey,
|
||||
size_px: 8,
|
||||
usage_count: 1,
|
||||
source: 'system'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(detectedFonts).length === 0) {
|
||||
container.innerHTML = '<div class="text-gray-400">No fonts detected yet (managers will register fonts when they render)</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const lines = [];
|
||||
for (const [elementKey, fontInfo] of Object.entries(detectedFonts)) {
|
||||
const sourceStr = fontInfo.source === 'override' ? ' [OVERRIDE]' : ' [SYSTEM]';
|
||||
lines.push(`${elementKey}: ${fontInfo.family}@${fontInfo.size_px}px (used ${fontInfo.usage_count}x)${sourceStr}`);
|
||||
}
|
||||
container.textContent = lines.join('\n');
|
||||
}
|
||||
|
||||
function updateAvailableFontsDisplay() {
|
||||
const container = document.getElementById('available-fonts');
|
||||
if (!container) return;
|
||||
@@ -594,10 +452,9 @@ async function deleteFont(fontFamily) {
|
||||
|
||||
function populateFontSelects() {
|
||||
// Populate font family dropdowns from catalog
|
||||
const overrideSelect = document.getElementById('override-family');
|
||||
const previewSelect = document.getElementById('preview-family');
|
||||
|
||||
if (!overrideSelect || !previewSelect) return;
|
||||
if (!previewSelect) return;
|
||||
|
||||
// Get font entries sorted by display name
|
||||
const fontEntries = Object.entries(fontCatalog).map(([key, info]) => {
|
||||
@@ -608,13 +465,6 @@ function populateFontSelects() {
|
||||
}).sort((a, b) => a.displayName.localeCompare(b.displayName));
|
||||
|
||||
// Build options using DOM APIs to prevent XSS
|
||||
// Clear and add default option for override select
|
||||
overrideSelect.innerHTML = '';
|
||||
const defaultOption = document.createElement('option');
|
||||
defaultOption.value = '';
|
||||
defaultOption.textContent = 'Use default';
|
||||
overrideSelect.appendChild(defaultOption);
|
||||
|
||||
// Clear preview select
|
||||
previewSelect.innerHTML = '';
|
||||
|
||||
@@ -622,11 +472,6 @@ function populateFontSelects() {
|
||||
fontEntries.forEach(font => {
|
||||
const typeLabel = font.fontType ? ` (${font.fontType})` : '';
|
||||
|
||||
const overrideOpt = document.createElement('option');
|
||||
overrideOpt.value = font.filename;
|
||||
overrideOpt.textContent = font.displayName + typeLabel;
|
||||
overrideSelect.appendChild(overrideOpt);
|
||||
|
||||
const previewOpt = document.createElement('option');
|
||||
previewOpt.value = font.filename;
|
||||
previewOpt.textContent = font.displayName + typeLabel;
|
||||
@@ -641,187 +486,6 @@ function populateFontSelects() {
|
||||
console.log(`Populated font selects with ${fontEntries.length} fonts`);
|
||||
}
|
||||
|
||||
async function addFontOverride() {
|
||||
const element = document.getElementById('override-element').value;
|
||||
const family = document.getElementById('override-family').value;
|
||||
const sizeToken = document.getElementById('override-size').value;
|
||||
|
||||
if (!element) {
|
||||
showNotification('Please select an element', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!family && !sizeToken) {
|
||||
showNotification('Please specify at least a font family or size', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const overrideData = {};
|
||||
if (family) overrideData.family = family;
|
||||
if (sizeToken) {
|
||||
const sizePx = fontTokens[sizeToken];
|
||||
if (sizePx) overrideData.size_px = sizePx;
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/v3/fonts/overrides`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
[element]: overrideData
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
let message;
|
||||
try {
|
||||
const errorData = JSON.parse(text);
|
||||
message = errorData.message || `Server error: ${response.status}`;
|
||||
} catch {
|
||||
message = `Server error: ${response.status}`;
|
||||
}
|
||||
showNotification('Error adding font override: ' + message, 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
if (data.status === 'success') {
|
||||
showNotification('Font override added successfully', 'success');
|
||||
await loadFontData();
|
||||
displayCurrentOverrides();
|
||||
// Clear form
|
||||
document.getElementById('override-element').value = '';
|
||||
document.getElementById('override-family').value = '';
|
||||
document.getElementById('override-size').value = '';
|
||||
} else {
|
||||
showNotification('Error adding font override: ' + data.message, 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error adding font override:', error);
|
||||
showNotification('Error adding font override: ' + error, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteFontOverride(elementKey) {
|
||||
if (!confirm(`Are you sure you want to remove the font override for "${elementKey}"?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/v3/fonts/overrides/${elementKey}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
let message;
|
||||
try {
|
||||
const errorData = JSON.parse(text);
|
||||
message = errorData.message || `Server error: ${response.status}`;
|
||||
} catch {
|
||||
message = `Server error: ${response.status}`;
|
||||
}
|
||||
showNotification('Error removing font override: ' + message, 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
if (data.status === 'success') {
|
||||
showNotification('Font override removed successfully', 'success');
|
||||
await loadFontData();
|
||||
displayCurrentOverrides();
|
||||
} else {
|
||||
showNotification('Error removing font override: ' + data.message, 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting font override:', error);
|
||||
showNotification('Error removing font override: ' + error, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function displayCurrentOverrides() {
|
||||
const container = document.getElementById('overrides-list');
|
||||
if (!container) return;
|
||||
|
||||
if (Object.keys(fontOverrides).length === 0) {
|
||||
container.innerHTML = '<div class="text-gray-500 text-sm italic">No font overrides configured</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Build list using DOM APIs to prevent XSS
|
||||
container.innerHTML = '';
|
||||
Object.entries(fontOverrides).forEach(([elementKey, override]) => {
|
||||
const elementName = getElementDisplayName(elementKey);
|
||||
const settings = [];
|
||||
|
||||
if (override.family) {
|
||||
const familyName = getFontDisplayName(override.family);
|
||||
settings.push(`Family: ${familyName}`);
|
||||
}
|
||||
|
||||
if (override.size_px) {
|
||||
settings.push(`Size: ${override.size_px}px`);
|
||||
}
|
||||
|
||||
const row = document.createElement('div');
|
||||
row.className = 'flex items-center justify-between p-3 bg-white rounded border';
|
||||
|
||||
const infoDiv = document.createElement('div');
|
||||
|
||||
const nameDiv = document.createElement('div');
|
||||
nameDiv.className = 'font-medium text-gray-900';
|
||||
nameDiv.textContent = elementName;
|
||||
|
||||
const settingsDiv = document.createElement('div');
|
||||
settingsDiv.className = 'text-sm text-gray-600';
|
||||
settingsDiv.textContent = settings.join(', ');
|
||||
|
||||
infoDiv.appendChild(nameDiv);
|
||||
infoDiv.appendChild(settingsDiv);
|
||||
|
||||
const deleteBtn = document.createElement('button');
|
||||
deleteBtn.className = 'btn bg-red-600 hover:bg-red-700 text-white px-3 py-1 text-sm';
|
||||
const trashIcon = document.createElement('i');
|
||||
trashIcon.className = 'fas fa-trash mr-1';
|
||||
deleteBtn.appendChild(trashIcon);
|
||||
deleteBtn.appendChild(document.createTextNode('Remove'));
|
||||
deleteBtn.dataset.elementKey = elementKey;
|
||||
deleteBtn.addEventListener('click', function() {
|
||||
deleteFontOverride(this.dataset.elementKey);
|
||||
});
|
||||
|
||||
row.appendChild(infoDiv);
|
||||
row.appendChild(deleteBtn);
|
||||
container.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
function getElementDisplayName(elementKey) {
|
||||
const names = {
|
||||
'nfl.live.score': 'NFL Live Score',
|
||||
'nfl.live.time': 'NFL Live Time',
|
||||
'nfl.live.team': 'NFL Live Team',
|
||||
'mlb.live.score': 'MLB Live Score',
|
||||
'nhl.live.score': 'NHL Live Score',
|
||||
'nba.live.score': 'NBA Live Score',
|
||||
'clock.time': 'Clock Time',
|
||||
'clock.date': 'Clock Date',
|
||||
'weather.current': 'Weather Current',
|
||||
'weather.forecast': 'Weather Forecast'
|
||||
};
|
||||
return names[elementKey] || elementKey;
|
||||
}
|
||||
|
||||
function getFontDisplayName(fontKey) {
|
||||
const names = {
|
||||
'press_start': 'Press Start 2P',
|
||||
'four_by_six': '4x6 Font',
|
||||
'matrix_light_6': 'Matrix Light 6'
|
||||
};
|
||||
return names[fontKey] || fontKey;
|
||||
}
|
||||
|
||||
async function updateFontPreview() {
|
||||
const previewImage = document.getElementById('font-preview-image');
|
||||
const loadingText = document.getElementById('font-preview-loading');
|
||||
|
||||
@@ -71,6 +71,76 @@
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
{% elif obj_widget == 'style-editor' %}
|
||||
{# Composite per-element style editor. It renders its own inputs
|
||||
with the same dotted names the generic renderer would produce
|
||||
(customization.score_text.font, ...text_color.0, ...), so the
|
||||
save/validate/merge pipeline is untouched -- no hidden JSON
|
||||
blob, no new server-side parsing. It falls back to the normal
|
||||
nested rendering if the widget cannot be loaded. #}
|
||||
{% set obj_value = value if value is not none else {} %}
|
||||
<div class="form-group mb-4" id="setting-{{ field_id }}" data-setting-key="{{ full_key }}">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">{{ label }}{{ ui.help_tip(description, label) }}</label>
|
||||
<div id="{{ field_id }}_container" class="style-editor-container mt-1"></div>
|
||||
<div id="{{ field_id }}_fallback">
|
||||
{{ render_nested_section(key, prop, value, prefix, plugin_id) }}
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
(function() {
|
||||
function fallbackIsDirty(root) {
|
||||
if (!root) { return false; }
|
||||
var controls = root.querySelectorAll('input, select, textarea');
|
||||
for (var i = 0; i < controls.length; i++) {
|
||||
var c = controls[i];
|
||||
if (c.type === 'checkbox' || c.type === 'radio') {
|
||||
if (c.checked !== c.defaultChecked) { return true; }
|
||||
} else if (c.tagName === 'SELECT') {
|
||||
for (var j = 0; j < c.options.length; j++) {
|
||||
if (c.options[j].selected !== c.options[j].defaultSelected) { return true; }
|
||||
}
|
||||
} else if (c.value !== c.defaultValue) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
function initWidget() {
|
||||
if (!window.LEDMatrixWidgets) { setTimeout(initWidget, 50); return; }
|
||||
var widget = window.LEDMatrixWidgets.get('style-editor');
|
||||
if (!widget) { setTimeout(initWidget, 50); return; }
|
||||
var container = document.getElementById('{{ field_id }}_container');
|
||||
var fallback = document.getElementById('{{ field_id }}_fallback');
|
||||
if (!container) { return; }
|
||||
// A user may already be editing the plain fallback fields
|
||||
// while this widget's script is still loading; swapping
|
||||
// in the widget now would replace them with the stale
|
||||
// server-rendered value and silently drop that edit.
|
||||
if (fallbackIsDirty(fallback)) { container.remove(); return; }
|
||||
var value = {{ obj_value|tojson|safe }};
|
||||
var config = { schema: {{ prop|tojson|safe }} };
|
||||
try {
|
||||
widget.render(container, config, value, {
|
||||
fieldId: '{{ field_id }}',
|
||||
name: '{{ full_key }}',
|
||||
pluginId: '{{ plugin_id }}'
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('[style-editor] render failed', e);
|
||||
container.remove();
|
||||
return;
|
||||
}
|
||||
// The widget owns these fields now; drop the generic
|
||||
// rendering so the form does not post both.
|
||||
if (fallback) { fallback.remove(); }
|
||||
}
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', initWidget);
|
||||
} else {
|
||||
setTimeout(initWidget, 50);
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
{% elif prop.properties %}
|
||||
{{ render_nested_section(key, prop, value, prefix, plugin_id) }}
|
||||
{% endif %}
|
||||
@@ -872,6 +942,68 @@
|
||||
name="{{ full_key }}"
|
||||
value="{{ str_value }}"
|
||||
class="form-input w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 bg-white text-black placeholder:text-gray-500">
|
||||
{% if str_widget %}
|
||||
{# An x-widget the core does not ship may be supplied by the
|
||||
plugin itself (manifest "widgets", served from its widgets/
|
||||
directory). Ask the loader for it; the text input above is
|
||||
the fallback and stays put unless the widget really renders,
|
||||
so a missing or broken widget degrades to an editable field
|
||||
rather than dropping the value on save. #}
|
||||
<div id="{{ field_id }}_container" class="{{ str_widget }}-container"></div>
|
||||
<script>
|
||||
(function() {
|
||||
var WIDGET = {{ str_widget|tojson|safe }};
|
||||
var PLUGIN = {{ plugin_id|tojson|safe }};
|
||||
function initPluginWidget() {
|
||||
if (!window.LEDMatrixWidgets || !window.LEDMatrixWidgets.ensureWidget) {
|
||||
setTimeout(initPluginWidget, 50);
|
||||
return;
|
||||
}
|
||||
var container = document.getElementById('{{ field_id }}_container');
|
||||
var fallback = document.getElementById('{{ field_id }}');
|
||||
if (!container) { return; }
|
||||
window.LEDMatrixWidgets.ensureWidget(WIDGET, PLUGIN).then(function(available) {
|
||||
if (!available) { container.remove(); return; }
|
||||
var widget = window.LEDMatrixWidgets.get(WIDGET);
|
||||
if (!widget || typeof widget.render !== 'function') { container.remove(); return; }
|
||||
// Read the fallback's live value rather than the
|
||||
// server-rendered one, so an edit typed while the
|
||||
// widget script was still loading isn't lost.
|
||||
var value = fallback ? fallback.value : {{ str_value|tojson|safe }};
|
||||
var config = {
|
||||
'type': '{{ field_type }}',
|
||||
'enum': {{ (prop.enum or [])|tojson|safe }},
|
||||
'x-options': {{ (prop.get('x-options') or prop.get('x_options') or {})|tojson|safe }},
|
||||
'x-widget-config': {{ (prop.get('x-widget-config') or prop.get('x_widget_config') or {})|tojson|safe }}
|
||||
};
|
||||
try {
|
||||
widget.render(container, config, value, {
|
||||
fieldId: '{{ field_id }}',
|
||||
name: '{{ full_key }}',
|
||||
pluginId: PLUGIN
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('[PluginWidget] render failed for ' + WIDGET, e);
|
||||
container.remove();
|
||||
return;
|
||||
}
|
||||
// Only now is the widget authoritative for this
|
||||
// field; drop the fallback so the form posts one
|
||||
// value rather than two under the same name.
|
||||
if (fallback) { fallback.remove(); }
|
||||
}).catch(function(e) {
|
||||
console.warn('[PluginWidget] could not load ' + WIDGET, e);
|
||||
container.remove();
|
||||
});
|
||||
}
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', initPluginWidget);
|
||||
} else {
|
||||
setTimeout(initPluginWidget, 50);
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user