mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-04-10 13:02:59 +00:00
fix: post-audit follow-up code fixes (cache, fonts, icons, dev script) (#307)
* fix: post-audit follow-up code fixes (cache, fonts, icons, dev script, CI) The docs refresh effort (#306, ledmatrix-plugins#92) surfaced seven code bugs that were intentionally left out of the docs PRs because they required code changes rather than doc fixes. This PR addresses the six that belong in LEDMatrix (the seventh — a lacrosse-scoreboard mode rename — lives in the plugins repo). Bug 1: cache_manager.delete() AttributeError src/common/api_helper.py:287 and src/plugin_system/resource_monitor.py:343 both call cache_manager.delete(key), which doesn't exist — only clear_cache(key=None). Added a delete() alias method on CacheManager that forwards to clear_cache(key). Reverts the "There is no delete() method" wording in DEVELOPER_QUICK_REFERENCE, .cursorrules so the docs match the new shim. Bug 2: dev_plugin_setup.sh PROJECT_ROOT resolution scripts/dev/dev_plugin_setup.sh:9 set PROJECT_ROOT to SCRIPT_DIR instead of walking up two levels to the repo root, so PLUGINS_DIR resolved to scripts/dev/plugins/ and created symlinks under the script's own directory. Fixed the path and removed the stray scripts/dev/plugins/of-the-day symlink left by earlier runs. Bug 3: plugin custom icons regressed from v2 to v3 web_interface/blueprints/api_v3.py built the /plugins/installed response without including the manifest's "icon" field, and web_interface/templates/v3/base.html hardcoded fas fa-puzzle-piece in all three plugin-tab render sites. Pass the icon through the API and read it from the templates with a puzzle-piece fallback. Reverts the "currently broken" banners in docs/PLUGIN_CUSTOM_ICONS.md and docs/PLUGIN_CUSTOM_ICONS_FEATURE.md. Bug 4: register_plugin_fonts was never wired up src/font_manager.py:150 defines register_plugin_fonts(plugin_id, font_manifest) but nothing called it, so plugin manifests with a "fonts" block were silently no-ops. Wired the call into PluginManager.load_plugin() right after plugin_loader.load_plugin returns. Reverts the "not currently wired" warning in docs/FONT_MANAGER.md's "For Plugin Developers" section. Bug 5: dead web_interface_v2 import pattern (LEDMatrix half) src/base_odds_manager.py had a try/except importing web_interface_v2.increment_api_counter, falling back to a no-op stub. The module doesn't exist anywhere in the v3 codebase and no API metrics dashboard reads it. Deleted the import block and the single call site; the plugins-repo half of this cleanup lands in ledmatrix-plugins#<next>. Bug 7: no CI test workflow .github/workflows/ only contained security-audit.yml; pytest ran locally but was not gated on PRs. Added .github/workflows/tests.yml running pytest against Python 3.10, 3.11, 3.12 in EMULATOR=true mode, skipping tests marked hardware or slow. Updated docs/HOW_TO_RUN_TESTS.md to reflect that the workflow now exists. Verification done locally: - CacheManager.delete(key) round-trips with set/get - base_odds_manager imports without the v2 module present - dev_plugin_setup.sh PROJECT_ROOT resolves to repo root - api_v3 and plugin_manager compile clean - tests.yml YAML parses - Script syntax check on dev_plugin_setup.sh Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: address CodeRabbit review comments on #307 - src/cache_manager.py: clear_cache(key) treated empty string as "wipe all" because of `if key:`. Switched to `key is None` branching, made delete(key) and clear_cache(key) reject empty strings and None outright with ValueError, and updated both docstrings to make the contract explicit. Verified locally with a round-trip test that clear_cache() (no arg) still wipes everything but clear_cache("") and delete("") raise. - src/plugin_system/plugin_manager.py: was reaching for the font manager via getattr(self.display_manager, 'font_manager', None). PluginManager already takes a dedicated font_manager parameter (line 54) and stores it as self.font_manager (line 69), so the old path was both wrong and could miss the font manager entirely when the host injects them separately. Switched to self.font_manager directly with the same try/except warning behavior. - web_interface/templates/v3/base.html: in the full plugin-tab renderer, the icon was injected with `<i class="${escapeHtml(plugin.icon)}">` — but escapeHtml only escapes <, >, and &, not double quotes, so a manifest with a quote in its icon string could break out of the class attribute. Replaced the innerHTML template with createElement for the <i> tag, set className from plugin.icon directly (no string interpolation), and used a text node for the label. Same fix shape would also harden the two stub-renderer sites at line 515 / 774, but those already escape `"` to " and CodeRabbit only flagged this site, so leaving them for now. - docs/FONT_MANAGER.md: clarified that the Manual Font Overrides *workflow* (set_override / remove_override / font_overrides.json) is the supported override path today, and only the Fonts tab in the web UI is the placeholder. Previous wording conflated the two and made it sound like overrides themselves were broken. - docs/HOW_TO_RUN_TESTS.md: replaced the vague "see the PR adding it" with a concrete link to #307 and a note that the workflow file itself is held back pending the workflow scope. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Chuck <chuck@example.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -195,8 +195,9 @@ Located in: `src/cache_manager.py`
|
|||||||
**Key Methods:**
|
**Key Methods:**
|
||||||
- `get(key, max_age=300)`: Get cached value (returns None if missing/stale)
|
- `get(key, max_age=300)`: Get cached value (returns None if missing/stale)
|
||||||
- `set(key, value, ttl=None)`: Cache a value
|
- `set(key, value, ttl=None)`: Cache a value
|
||||||
- `clear_cache(key=None)`: Remove a cache entry, or all entries if `key`
|
- `delete(key)` / `clear_cache(key=None)`: Remove a single cache entry,
|
||||||
is omitted. There is no `delete()` method.
|
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
|
- `get_cached_data_with_strategy(key, data_type)`: Cache get with
|
||||||
data-type-aware TTL strategy
|
data-type-aware TTL strategy
|
||||||
- `get_background_cached_data(key, sport_key)`: Cache get for the
|
- `get_background_cached_data(key, sport_key)`: Cache get for the
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ display_manager.defer_update(lambda: self.update_cache(), priority=0)
|
|||||||
# Basic caching
|
# Basic caching
|
||||||
cached = cache_manager.get("key", max_age=3600)
|
cached = cache_manager.get("key", max_age=3600)
|
||||||
cache_manager.set("key", data)
|
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
|
# Advanced caching
|
||||||
data = cache_manager.get_cached_data_with_strategy("key", data_type="weather")
|
data = cache_manager.get_cached_data_with_strategy("key", data_type="weather")
|
||||||
|
|||||||
@@ -138,29 +138,28 @@ font = self.font_manager.resolve_font(
|
|||||||
|
|
||||||
## For Plugin Developers
|
## For Plugin Developers
|
||||||
|
|
||||||
> ⚠️ **Status**: the plugin-font registration described below is
|
> **Note**: plugins that ship their own fonts via a `"fonts"` block
|
||||||
> implemented in `src/font_manager.py:150` (`register_plugin_fonts()`)
|
> in `manifest.json` are registered automatically during plugin load
|
||||||
> but is **not currently wired into the plugin loader**. Adding a
|
> (`src/plugin_system/plugin_manager.py` calls
|
||||||
> `"fonts"` block to your plugin's `manifest.json` will silently have
|
> `FontManager.register_plugin_fonts()`). The `plugin://…` source
|
||||||
> no effect — the FontManager method exists but nothing calls it.
|
> URIs documented below are resolved relative to the plugin's
|
||||||
|
> install directory.
|
||||||
>
|
>
|
||||||
> Until that's connected, plugin authors who need a custom font
|
> The **Fonts** tab in the web UI that lists detected
|
||||||
> should load it directly with PIL (or `freetype-py` for BDF) in
|
> manager-registered fonts is still a **placeholder
|
||||||
> their plugin's `manager.py` — `FontManager.resolve_font(family=…,
|
> implementation** — fonts that managers register through
|
||||||
> size_px=…)` takes a **family name**, not a file path, so it can't
|
> `register_manager_font()` do not yet appear there. The
|
||||||
> be used to pull a font from your plugin directory. The
|
> programmatic per-element override workflow described in
|
||||||
> `plugin://…` source URIs described below are only honored by
|
> [Manual Font Overrides](#manual-font-overrides) below
|
||||||
> `register_plugin_fonts()` itself, which isn't wired up.
|
> (`set_override()` / `remove_override()` / the
|
||||||
>
|
> `config/font_overrides.json` store) **does** work today and is
|
||||||
> The `/api/v3/fonts/overrides` endpoints and the **Fonts** tab in
|
> the supported way to override a font for an element until the
|
||||||
> the web UI are currently **placeholder implementations** — they
|
> Fonts tab is wired up. If you can't wait and need a workaround
|
||||||
> return empty arrays and contain "would integrate with the actual
|
> right now, you can also just load the font directly with PIL
|
||||||
> font system" comments. Manually registered manager fonts do
|
> (or `freetype-py` for BDF) inside your plugin's `manager.py`
|
||||||
> **not** yet flow into that tab. If you need an override today,
|
> and skip the override system entirely.
|
||||||
> load the font directly in your plugin and skip the
|
|
||||||
> override system.
|
|
||||||
|
|
||||||
### Plugin Font Registration (planned)
|
### Plugin Font Registration
|
||||||
|
|
||||||
In your plugin's `manifest.json`:
|
In your plugin's `manifest.json`:
|
||||||
|
|
||||||
|
|||||||
@@ -336,15 +336,15 @@ pytest --cov=src --cov-report=html
|
|||||||
|
|
||||||
## Continuous Integration
|
## Continuous Integration
|
||||||
|
|
||||||
There is currently no CI test workflow in this repo — `pytest` runs
|
The repo runs
|
||||||
locally but is not gated on PRs. The only GitHub Actions workflow is
|
[`.github/workflows/security-audit.yml`](../.github/workflows/security-audit.yml)
|
||||||
[`.github/workflows/security-audit.yml`](../.github/workflows/security-audit.yml),
|
(bandit + semgrep) on every push. A pytest CI workflow at
|
||||||
which runs bandit and semgrep on every push.
|
`.github/workflows/tests.yml` is queued to land alongside this
|
||||||
|
PR ([ChuckBuilds/LEDMatrix#307](https://github.com/ChuckBuilds/LEDMatrix/pull/307));
|
||||||
If you'd like to add a test workflow, the recommended setup is a
|
the workflow file itself was held back from that PR because the
|
||||||
`.github/workflows/tests.yml` that runs `pytest` against the
|
push token lacked the GitHub `workflow` scope, so it needs to be
|
||||||
supported Python versions (3.10, 3.11, 3.12, 3.13 per
|
committed separately by a maintainer. Once it's in, this section
|
||||||
`requirements.txt`). Open an issue or PR if you want to contribute it.
|
will be updated to describe what the job runs.
|
||||||
|
|
||||||
## Best Practices
|
## Best Practices
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +1,5 @@
|
|||||||
# Plugin Custom Icons Guide
|
# 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
|
## 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.
|
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.
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
# Plugin Custom Icons Feature
|
# Plugin Custom Icons Feature
|
||||||
|
|
||||||
> ⚠️ **Status:** this doc describes the v2 web interface
|
> **Note:** this doc was originally written against the v2 web
|
||||||
> implementation of plugin custom icons. The feature **regressed when
|
> interface. The v3 web interface now honors the same `icon` field
|
||||||
> the v3 web interface was built** — the `getPluginIcon()` helper
|
> in `manifest.json` — the API passes it through at
|
||||||
> referenced below lived in `templates/index_v2.html` (which is now
|
> `web_interface/blueprints/api_v3.py` and the three plugin-tab
|
||||||
> archived) and was not ported to the v3 templates. Plugin tab icons
|
> render sites in `web_interface/templates/v3/base.html` read it
|
||||||
> in v3 are hardcoded to `fas fa-puzzle-piece`
|
> with a `fas fa-puzzle-piece` fallback. The guidance below still
|
||||||
> (`web_interface/templates/v3/base.html:515` and `:774`). The
|
> applies; only the referenced template/helper names differ.
|
||||||
> `icon` field in `manifest.json` is currently silently ignored.
|
|
||||||
|
|
||||||
## What Was Implemented
|
## What Was Implemented
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
PROJECT_ROOT="$SCRIPT_DIR"
|
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
||||||
PLUGINS_DIR="$PROJECT_ROOT/plugins"
|
PLUGINS_DIR="$PROJECT_ROOT/plugins"
|
||||||
CONFIG_FILE="$PROJECT_ROOT/dev_plugins.json"
|
CONFIG_FILE="$PROJECT_ROOT/dev_plugins.json"
|
||||||
DEFAULT_DEV_DIR="$HOME/.ledmatrix-dev-plugins"
|
DEFAULT_DEV_DIR="$HOME/.ledmatrix-dev-plugins"
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
/home/chuck/.ledmatrix-dev-plugins/ledmatrix-of-the-day
|
|
||||||
@@ -19,14 +19,6 @@ from datetime import datetime, timedelta, timezone
|
|||||||
from typing import Dict, Any, Optional, List
|
from typing import Dict, Any, Optional, List
|
||||||
import pytz
|
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:
|
class BaseOddsManager:
|
||||||
"""
|
"""
|
||||||
@@ -132,8 +124,6 @@ class BaseOddsManager:
|
|||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
raw_data = response.json()
|
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)}")
|
self.logger.debug(f"Received raw odds data from ESPN: {json.dumps(raw_data, indent=2)}")
|
||||||
|
|
||||||
odds_data = self._extract_espn_data(raw_data)
|
odds_data = self._extract_espn_data(raw_data)
|
||||||
|
|||||||
@@ -320,18 +320,43 @@ class CacheManager:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
def clear_cache(self, key: Optional[str] = None) -> None:
|
def clear_cache(self, key: Optional[str] = None) -> None:
|
||||||
"""Clear cache for a specific key or all keys."""
|
"""Clear cache entries.
|
||||||
if key:
|
|
||||||
# Clear specific key
|
Pass a non-empty ``key`` to remove a single entry, or pass
|
||||||
self._memory_cache_component.clear(key)
|
``None`` (the default) to clear every cached entry. An empty
|
||||||
self._disk_cache_component.clear(key)
|
string is rejected to prevent accidental whole-cache wipes
|
||||||
self.logger.info("Cleared cache for key: %s", key)
|
from callers that pass through unvalidated input.
|
||||||
else:
|
"""
|
||||||
|
if key is None:
|
||||||
# Clear all keys
|
# Clear all keys
|
||||||
memory_count = self._memory_cache_component.size()
|
memory_count = self._memory_cache_component.size()
|
||||||
self._memory_cache_component.clear()
|
self._memory_cache_component.clear()
|
||||||
self._disk_cache_component.clear()
|
self._disk_cache_component.clear()
|
||||||
self.logger.info("Cleared all cache: %d memory entries", memory_count)
|
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]]:
|
def list_cache_files(self) -> List[Dict[str, Any]]:
|
||||||
"""List all cache files with metadata (key, age, size, path).
|
"""List all cache files with metadata (key, age, size, path).
|
||||||
|
|||||||
@@ -359,6 +359,22 @@ class PluginManager:
|
|||||||
# Store module
|
# Store module
|
||||||
self.plugin_modules[plugin_id] = 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
|
# Validate configuration
|
||||||
if hasattr(plugin_instance, 'validate_config'):
|
if hasattr(plugin_instance, 'validate_config'):
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -1824,6 +1824,7 @@ def get_installed_plugins():
|
|||||||
'category': plugin_info.get('category', 'General'),
|
'category': plugin_info.get('category', 'General'),
|
||||||
'description': plugin_info.get('description', 'No description available'),
|
'description': plugin_info.get('description', 'No description available'),
|
||||||
'tags': plugin_info.get('tags', []),
|
'tags': plugin_info.get('tags', []),
|
||||||
|
'icon': plugin_info.get('icon', 'fas fa-puzzle-piece'),
|
||||||
'enabled': enabled,
|
'enabled': enabled,
|
||||||
'verified': verified,
|
'verified': verified,
|
||||||
'loaded': plugin_info.get('loaded', False),
|
'loaded': plugin_info.get('loaded', False),
|
||||||
|
|||||||
@@ -512,7 +512,8 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
tabButton.innerHTML = `<i class="fas fa-puzzle-piece"></i>${(plugin.name || plugin.id).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')}`;
|
const iconClass = (plugin.icon || 'fas fa-puzzle-piece').replace(/"/g, '"');
|
||||||
|
tabButton.innerHTML = `<i class="${iconClass}"></i>${(plugin.name || plugin.id).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')}`;
|
||||||
pluginTabsNav.appendChild(tabButton);
|
pluginTabsNav.appendChild(tabButton);
|
||||||
});
|
});
|
||||||
console.log('[GLOBAL] Updated plugin tabs directly:', plugins.length, 'tabs added');
|
console.log('[GLOBAL] Updated plugin tabs directly:', plugins.length, 'tabs added');
|
||||||
@@ -771,7 +772,8 @@
|
|||||||
};
|
};
|
||||||
const div = document.createElement('div');
|
const div = document.createElement('div');
|
||||||
div.textContent = plugin.name || plugin.id;
|
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, '"');
|
||||||
|
tabButton.innerHTML = `<i class="${iconClass}"></i>${div.innerHTML}`;
|
||||||
pluginTabsNav.appendChild(tabButton);
|
pluginTabsNav.appendChild(tabButton);
|
||||||
});
|
});
|
||||||
console.log('[STUB] updatePluginTabs: Added', this.installedPlugins.length, 'plugin tabs');
|
console.log('[STUB] updatePluginTabs: Added', this.installedPlugins.length, 'plugin tabs');
|
||||||
@@ -1959,9 +1961,15 @@
|
|||||||
this.updatePluginTabStates();
|
this.updatePluginTabStates();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
tabButton.innerHTML = `
|
// Build the <i class="..."> + label as DOM nodes so a
|
||||||
<i class="fas fa-puzzle-piece"></i>${this.escapeHtml(plugin.name || plugin.id)}
|
// 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
|
// Insert before the closing </nav> tag
|
||||||
pluginTabsNav.appendChild(tabButton);
|
pluginTabsNav.appendChild(tabButton);
|
||||||
|
|||||||
Reference in New Issue
Block a user