Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions .cursorrules
Original file line number Diff line number Diff line change
Expand Up @@ -195,8 +195,9 @@ Located in: `src/cache_manager.py`
**Key Methods:**
- `get(key, max_age=300)`: Get cached value (returns None if missing/stale)
- `set(key, value, ttl=None)`: Cache a value
- `clear_cache(key=None)`: Remove a cache entry, or all entries if `key`
is omitted. There is no `delete()` method.
- `delete(key)` / `clear_cache(key=None)`: Remove a single cache entry,
or (for `clear_cache` with no argument) every cached entry. `delete`
is an alias for `clear_cache(key)`.
- `get_cached_data_with_strategy(key, data_type)`: Cache get with
data-type-aware TTL strategy
- `get_background_cached_data(key, sport_key)`: Cache get for the
Expand Down
2 changes: 1 addition & 1 deletion docs/DEVELOPER_QUICK_REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ display_manager.defer_update(lambda: self.update_cache(), priority=0)
# Basic caching
cached = cache_manager.get("key", max_age=3600)
cache_manager.set("key", data)
cache_manager.clear_cache("key") # there is no delete() method
cache_manager.delete("key") # alias for clear_cache(key)

# Advanced caching
data = cache_manager.get_cached_data_with_strategy("key", data_type="weather")
Expand Down
43 changes: 21 additions & 22 deletions docs/FONT_MANAGER.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,29 +138,28 @@ font = self.font_manager.resolve_font(

## For Plugin Developers

> ⚠️ **Status**: the plugin-font registration described below is
> implemented in `src/font_manager.py:150` (`register_plugin_fonts()`)
> but is **not currently wired into the plugin loader**. Adding a
> `"fonts"` block to your plugin's `manifest.json` will silently have
> no effect — the FontManager method exists but nothing calls it.
> **Note**: plugins that ship their own fonts via a `"fonts"` block
> in `manifest.json` are registered automatically during plugin load
> (`src/plugin_system/plugin_manager.py` calls
> `FontManager.register_plugin_fonts()`). The `plugin://…` source
> URIs documented below are resolved relative to the plugin's
> install directory.
>
> Until that's connected, plugin authors who need a custom font
> should load it directly with PIL (or `freetype-py` for BDF) in
> their plugin's `manager.py` — `FontManager.resolve_font(family=…,
> size_px=…)` takes a **family name**, not a file path, so it can't
> be used to pull a font from your plugin directory. The
> `plugin://…` source URIs described below are only honored by
> `register_plugin_fonts()` itself, which isn't wired up.
>
> The `/api/v3/fonts/overrides` endpoints and the **Fonts** tab in
> the web UI are currently **placeholder implementations** — they
> return empty arrays and contain "would integrate with the actual
> font system" comments. Manually registered manager fonts do
> **not** yet flow into that tab. If you need an override today,
> load the font directly in your plugin and skip the
> override system.

### Plugin Font Registration (planned)
> The **Fonts** tab in the web UI that lists detected
> manager-registered fonts is still a **placeholder
> implementation** — fonts that managers register through
> `register_manager_font()` do not yet appear there. The
> programmatic per-element override workflow described in
> [Manual Font Overrides](#manual-font-overrides) below
> (`set_override()` / `remove_override()` / the
> `config/font_overrides.json` store) **does** work today and is
> the supported way to override a font for an element until the
> Fonts tab is wired up. If you can't wait and need a workaround
> right now, you can also just load the font directly with PIL
> (or `freetype-py` for BDF) inside your plugin's `manager.py`
> and skip the override system entirely.

### Plugin Font Registration

In your plugin's `manifest.json`:

Expand Down
18 changes: 9 additions & 9 deletions docs/HOW_TO_RUN_TESTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -336,15 +336,15 @@ pytest --cov=src --cov-report=html

## Continuous Integration

There is currently no CI test workflow in this repo — `pytest` runs
locally but is not gated on PRs. The only GitHub Actions workflow is
[`.github/workflows/security-audit.yml`](../.github/workflows/security-audit.yml),
which runs bandit and semgrep on every push.

If you'd like to add a test workflow, the recommended setup is a
`.github/workflows/tests.yml` that runs `pytest` against the
supported Python versions (3.10, 3.11, 3.12, 3.13 per
`requirements.txt`). Open an issue or PR if you want to contribute it.
The repo runs
[`.github/workflows/security-audit.yml`](../.github/workflows/security-audit.yml)
(bandit + semgrep) on every push. A pytest CI workflow at
`.github/workflows/tests.yml` is queued to land alongside this
PR ([ChuckBuilds/LEDMatrix#307](https://github.com/ChuckBuilds/LEDMatrix/pull/307));
the workflow file itself was held back from that PR because the
push token lacked the GitHub `workflow` scope, so it needs to be
committed separately by a maintainer. Once it's in, this section
will be updated to describe what the job runs.

## Best Practices

Expand Down
11 changes: 0 additions & 11 deletions docs/PLUGIN_CUSTOM_ICONS.md
Original file line number Diff line number Diff line change
@@ -1,16 +1,5 @@
# Plugin Custom Icons Guide

> ⚠️ **Status:** the `icon` field in `manifest.json` is currently
> **not honored by the v3 web interface**. Plugin tab icons are
> hardcoded to `fas fa-puzzle-piece` in
> `web_interface/templates/v3/base.html:515` and `:774`. The icon
> field was originally read by a `getPluginIcon()` helper in the v2
> templates, but that helper wasn't ported to v3. Setting `icon` in a
> manifest is harmless (it's just ignored) so plugin authors can leave
> it in place for when this regression is fixed.
>
> Tracking issue: see the LEDMatrix repo for the open ticket.

## Overview

Plugins can specify custom icons that appear next to their name in the web interface tabs. This makes your plugin instantly recognizable and adds visual polish to the UI.
Expand Down
15 changes: 7 additions & 8 deletions docs/PLUGIN_CUSTOM_ICONS_FEATURE.md
Original file line number Diff line number Diff line change
@@ -1,13 +1,12 @@
# Plugin Custom Icons Feature

> ⚠️ **Status:** this doc describes the v2 web interface
> implementation of plugin custom icons. The feature **regressed when
> the v3 web interface was built** — the `getPluginIcon()` helper
> referenced below lived in `templates/index_v2.html` (which is now
> archived) and was not ported to the v3 templates. Plugin tab icons
> in v3 are hardcoded to `fas fa-puzzle-piece`
> (`web_interface/templates/v3/base.html:515` and `:774`). The
> `icon` field in `manifest.json` is currently silently ignored.
> **Note:** this doc was originally written against the v2 web
> interface. The v3 web interface now honors the same `icon` field
> in `manifest.json` — the API passes it through at
> `web_interface/blueprints/api_v3.py` and the three plugin-tab
> render sites in `web_interface/templates/v3/base.html` read it
> with a `fas fa-puzzle-piece` fallback. The guidance below still
> applies; only the referenced template/helper names differ.

## What Was Implemented

Expand Down
2 changes: 1 addition & 1 deletion scripts/dev/dev_plugin_setup.sh
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$SCRIPT_DIR"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
PLUGINS_DIR="$PROJECT_ROOT/plugins"
CONFIG_FILE="$PROJECT_ROOT/dev_plugins.json"
DEFAULT_DEV_DIR="$HOME/.ledmatrix-dev-plugins"
Expand Down
1 change: 0 additions & 1 deletion scripts/dev/plugins/of-the-day

This file was deleted.

12 changes: 1 addition & 11 deletions src/base_odds_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,6 @@
from typing import Dict, Any, Optional, List
import pytz

# Import the API counter function from web interface
try:
from web_interface_v2 import increment_api_counter
except ImportError:
# Fallback if web interface is not available
def increment_api_counter(kind: str, count: int = 1):
pass


class BaseOddsManager:
"""
Expand Down Expand Up @@ -131,9 +123,7 @@ def get_odds(self, sport: str | None, league: str | None, event_id: str,
response = requests.get(url, timeout=self.request_timeout)
response.raise_for_status()
raw_data = response.json()

# Increment API counter for odds data
increment_api_counter('odds', 1)

self.logger.debug(f"Received raw odds data from ESPN: {json.dumps(raw_data, indent=2)}")

odds_data = self._extract_espn_data(raw_data)
Expand Down
39 changes: 32 additions & 7 deletions src/cache_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -320,18 +320,43 @@ def load_cache(self, key: str) -> Optional[Dict[str, Any]]:
return None

def clear_cache(self, key: Optional[str] = None) -> None:
"""Clear cache for a specific key or all keys."""
if key:
# Clear specific key
self._memory_cache_component.clear(key)
self._disk_cache_component.clear(key)
self.logger.info("Cleared cache for key: %s", key)
else:
"""Clear cache entries.

Pass a non-empty ``key`` to remove a single entry, or pass
``None`` (the default) to clear every cached entry. An empty
string is rejected to prevent accidental whole-cache wipes
from callers that pass through unvalidated input.
"""
if key is None:
# Clear all keys
memory_count = self._memory_cache_component.size()
self._memory_cache_component.clear()
self._disk_cache_component.clear()
self.logger.info("Cleared all cache: %d memory entries", memory_count)
return

if not isinstance(key, str) or not key:
raise ValueError(
"clear_cache(key) requires a non-empty string; "
"pass key=None to clear all entries"
)

# Clear specific key
self._memory_cache_component.clear(key)
self._disk_cache_component.clear(key)
self.logger.info("Cleared cache for key: %s", key)

def delete(self, key: str) -> None:
"""Remove a single cache entry.

Thin wrapper around :meth:`clear_cache` that **requires** a
non-empty string key — unlike ``clear_cache(None)`` it never
wipes every entry. Raises ``ValueError`` on ``None`` or an
empty string.
"""
if key is None or not isinstance(key, str) or not key:
raise ValueError("delete(key) requires a non-empty string key")
self.clear_cache(key)

def list_cache_files(self) -> List[Dict[str, Any]]:
"""List all cache files with metadata (key, age, size, path).
Expand Down
18 changes: 17 additions & 1 deletion src/plugin_system/plugin_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -358,7 +358,23 @@ def load_plugin(self, plugin_id: str) -> bool:

# Store module
self.plugin_modules[plugin_id] = module


# Register plugin-shipped fonts with the FontManager (if any).
# Plugin manifests can declare a "fonts" block that ships custom
# fonts with the plugin; FontManager.register_plugin_fonts handles
# the actual loading. Wired here so manifest declarations take
# effect without requiring plugin code changes.
font_manifest = manifest.get('fonts')
if font_manifest and self.font_manager is not None and hasattr(
self.font_manager, 'register_plugin_fonts'
):
try:
self.font_manager.register_plugin_fonts(plugin_id, font_manifest)
except Exception as e:
self.logger.warning(
"Failed to register fonts for plugin %s: %s", plugin_id, e
)

# Validate configuration
if hasattr(plugin_instance, 'validate_config'):
try:
Expand Down
1 change: 1 addition & 0 deletions web_interface/blueprints/api_v3.py
Original file line number Diff line number Diff line change
Expand Up @@ -1824,6 +1824,7 @@ def get_installed_plugins():
'category': plugin_info.get('category', 'General'),
'description': plugin_info.get('description', 'No description available'),
'tags': plugin_info.get('tags', []),
'icon': plugin_info.get('icon', 'fas fa-puzzle-piece'),
'enabled': enabled,
'verified': verified,
'loaded': plugin_info.get('loaded', False),
Expand Down
18 changes: 13 additions & 5 deletions web_interface/templates/v3/base.html
Original file line number Diff line number Diff line change
Expand Up @@ -512,7 +512,8 @@
}
}
};
tabButton.innerHTML = `<i class="fas fa-puzzle-piece"></i>${(plugin.name || plugin.id).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')}`;
const iconClass = (plugin.icon || 'fas fa-puzzle-piece').replace(/"/g, '&quot;');
tabButton.innerHTML = `<i class="${iconClass}"></i>${(plugin.name || plugin.id).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')}`;
pluginTabsNav.appendChild(tabButton);
});
console.log('[GLOBAL] Updated plugin tabs directly:', plugins.length, 'tabs added');
Expand Down Expand Up @@ -771,7 +772,8 @@
};
const div = document.createElement('div');
div.textContent = plugin.name || plugin.id;
tabButton.innerHTML = `<i class="fas fa-puzzle-piece"></i>${div.innerHTML}`;
const iconClass = (plugin.icon || 'fas fa-puzzle-piece').replace(/"/g, '&quot;');
tabButton.innerHTML = `<i class="${iconClass}"></i>${div.innerHTML}`;
pluginTabsNav.appendChild(tabButton);
});
console.log('[STUB] updatePluginTabs: Added', this.installedPlugins.length, 'plugin tabs');
Expand Down Expand Up @@ -1959,9 +1961,15 @@ <h1 class="text-xl font-bold text-gray-900">
this.updatePluginTabStates();
}
};
tabButton.innerHTML = `
<i class="fas fa-puzzle-piece"></i>${this.escapeHtml(plugin.name || plugin.id)}
`;
// Build the <i class="..."> + label as DOM nodes so a
// hostile plugin.icon (e.g. containing a quote) can't
// break out of the attribute. escapeHtml only escapes
// <, >, &, not ", so attribute-context interpolation
// would be unsafe.
const iconEl = document.createElement('i');
iconEl.className = plugin.icon || 'fas fa-puzzle-piece';
const labelNode = document.createTextNode(plugin.name || plugin.id);
tabButton.replaceChildren(iconEl, labelNode);

// Insert before the closing </nav> tag
pluginTabsNav.appendChild(tabButton);
Expand Down