mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-04-10 21:03:01 +00:00
Chaotic mega-merge into main. THINGS WILL PROBABLY BE BROKEN
* chore: Update soccer-scoreboard submodule to merged commit
- Update submodule reference to include manifest.json v2 registry format
- Version updated to 1.0.1
* refactor: Remove test_mode and logo_dir config reading from base SportsCore
- Remove test_mode initialization and usage
- Remove logo_dir reading from mode_config
- Use LogoDownloader defaults directly for logo directories
* chore: Update plugin submodules after removing global properties
- Update basketball-scoreboard submodule (removed global test_mode, live_priority, dynamic_duration, logo_dir)
- Update soccer-scoreboard submodule (removed global test_mode, live_priority, dynamic_duration, logo_dir)
* feat(calendar): Add credentials.json file upload via web interface
- Add API endpoint /api/v3/plugins/calendar/upload-credentials for file upload
- Validate JSON format and Google OAuth structure
- Save file to plugin directory with secure permissions (0o600)
- Backup existing credentials.json before overwriting
- Add file upload widget support for string fields in config forms
- Add frontend handler handleCredentialsUpload() for single file uploads
- Update .gitignore to allow calendar submodule
- Update calendar submodule reference
* fix(web): Improve spacing for nested configuration sections
- Add dynamic margin based on nesting depth (mb-6 for deeply nested sections)
- Increase padding in nested content areas (py-3 to py-4)
- Add extra spacing after nested sections to prevent overlap
- Enhance CSS spacing for nested sections (1.5rem for nested, 2rem for deeply nested)
- Add padding-bottom to expanded nested content to prevent cutoff
- Fixes issue where game_limits and other nested settings were hidden under next section header
* chore(plugins): Update sports scoreboard plugins with live update interval fix
- Updated hockey-scoreboard, football-scoreboard, basketball-scoreboard, and soccer-scoreboard submodules
- All plugins now fix the interval selection bug that caused live games to update every 5 minutes instead of 30 seconds
- Ensures all live games update at the configured live_update_interval (30s) for timely score updates
* fix: Initialize test_mode in SportsLive and fix config migration
- Add test_mode initialization in SportsLive.__init__() to prevent AttributeError
- Remove invalid new_secrets parameter from save_config_atomic() call in config migration
- Fixes errors: 'NBALiveManager' object has no attribute 'test_mode'
- Fixes errors: ConfigManager.save_config_atomic() got unexpected keyword argument 'new_secrets'
* chore: Update submodules with test_mode initialization fixes
- Update basketball-scoreboard submodule
- Update soccer-scoreboard submodule
* fix(plugins): Auto-stash local changes before plugin updates
- Automatically stash uncommitted changes before git pull during plugin updates
- Prevents update failures when plugins have local modifications
- Improves error messages for git update failures
- Matches behavior of main LEDMatrix update process
* fix(basketball-scoreboard): Update submodule with timeout fix
- Updated basketball-scoreboard plugin to fix update() timeout issue
- Plugin now uses fire-and-forget odds fetching for upcoming games
- Prevents 30-second timeout when processing many upcoming games
Also fixed permission issue on devpi:
- Changed /var/cache/ledmatrix/display_on_demand_state.json permissions
from 600 to 660 to allow web service (devpi user) to read the file
* fix(cache): Ensure cache files use 660 permissions for group access
- Updated setup_cache.sh to set file permissions to 660 (not 775)
- Updated first_time_install.sh to properly set cache file permissions
- Modified DiskCache to set 660 permissions when creating cache files
- Ensures display_on_demand_state.json and other cache files are readable
by web service (devpi user) which is in ledmatrix group
This fixes permission issues where cache files were created with 600
permissions, preventing the web service from reading them. Now files
are created with 660 (rw-rw----) allowing group read access.
* fix(soccer-scoreboard): Update submodule with manifest fix
- Updated soccer-scoreboard plugin submodule
- Added missing entry_point and class_name to manifest.json
- Fixes plugin loading error: 'No class_name in manifest'
Also fixed cache file permissions on devpi server:
- Changed display_on_demand_state.json from 600 to 660 permissions
- Allows web service (devpi user) to read cache files
* fix(display): Remove update_display() calls from clear() to prevent black flash
Previously, display_manager.clear() was calling update_display() twice,
which immediately showed a black screen on the hardware before new
content could be drawn. This caused visible black flashes when switching
between modes, especially when plugins switch from general modes (e.g.,
football_upcoming) to specific sub-modes (e.g., nfl_upcoming).
Now clear() only prepares the buffer without updating the hardware.
Callers can decide when to update the display, allowing smooth transitions
from clear → draw → update_display() without intermediate black flashes.
Places that intentionally show a cleared screen (error cases) already
explicitly call update_display() after clear(), so backward compatibility
is maintained.
* fix(scroll): Prevent wrap-around before cycle completion in dynamic duration
- Check scroll completion BEFORE allowing wrap-around
- Clamp scroll_position when complete to prevent visual loop
- Only wrap-around if cycle is not complete yet
- Fixes issue where stocks plugin showed first stock again at end
- Completion logged only once to avoid spam
- Ensures smooth transition to next mode without visual repeat
* fix(on-demand): Ensure on-demand buttons work and display service runs correctly
- Add early stub functions for on-demand modal to ensure availability when Alpine.js initializes
- Increase on-demand request cache max_age from 5min to 1hr to prevent premature expiration
- Fixes issue where on-demand buttons were not functional due to timing issues
- Ensures display service properly picks up on-demand requests when started
* test: Add comprehensive test coverage (30%+)
- Add 100+ new tests across core components
- Add tests for LayoutManager (27 tests)
- Add tests for PluginLoader (14 tests)
- Add tests for SchemaManager (20 tests)
- Add tests for MemoryCache and DiskCache (24 tests)
- Add tests for TextHelper (9 tests)
- Expand error handling tests (7 new tests)
- Improve coverage from 25.63% to 30.26%
- All 237 tests passing
Test files added:
- test/test_layout_manager.py
- test/test_plugin_loader.py
- test/test_schema_manager.py
- test/test_text_helper.py
- test/test_config_service.py
- test/test_display_controller.py
- test/test_display_manager.py
- test/test_error_handling.py
- test/test_font_manager.py
- test/test_plugin_system.py
Updated:
- pytest.ini: Enable coverage reporting with 30% threshold
- test/conftest.py: Enhanced fixtures for better test isolation
- test/test_cache_manager.py: Expanded cache component tests
- test/test_config_manager.py: Additional config tests
Documentation:
- HOW_TO_RUN_TESTS.md: Guide for running and understanding tests
* test(web): Add comprehensive API endpoint tests
- Add 30 new tests for Flask API endpoints in test/test_web_api.py
- Cover config, system, display, plugins, fonts, and error handling APIs
- Increase test coverage from 30.26% to 30.87%
- All 267 tests passing
Tests cover:
- Config API: GET/POST main config, schedule, secrets
- System API: Status, version, system actions
- Display API: Current display, on-demand start/stop
- Plugins API: Installed plugins, health, config, operations, state
- Fonts API: Catalog, tokens, overrides
- Error handling: Invalid JSON, missing fields, 404s
* test(plugins): Add comprehensive integration tests for all plugins
- Add base test class for plugin integration tests
- Create integration tests for all 6 plugins:
- basketball-scoreboard (11 tests)
- calendar (10 tests)
- clock-simple (11 tests)
- odds-ticker (9 tests)
- soccer-scoreboard (11 tests)
- text-display (12 tests)
- Total: 64 new plugin integration tests
- Increase test coverage from 30.87% to 33.38%
- All 331 tests passing
Tests verify:
- Plugin loading and instantiation
- Required methods (update, display)
- Manifest validation
- Display modes
- Config schema validation
- Graceful handling of missing API credentials
Uses hybrid approach: integration tests in main repo,
plugin-specific unit tests remain in plugin submodules.
* Add mqtt-notifications plugin as submodule
* fix(sports): Respect games_to_show settings for favorite teams
- Fix upcoming games to show N games per team (not just 1)
- Fix recent games to show N games per team (not just 1)
- Add duplicate removal for games involving multiple favorite teams
- Match behavior of basketball-scoreboard plugin
- Affects NFL, NHL, and other sports using base_classes/sports.py
* chore: Remove debug instrumentation logs
- Remove temporary debug logging added during fix verification
- Fix confirmed working by user
* debug: Add instrumentation to debug configuration header visibility issue
* fix: Resolve nested section content sliding under next header
- Remove overflow-hidden from nested-section to allow proper document flow
- Add proper z-index and positioning to prevent overlap
- Add margin-top to nested sections for better spacing
- Remove debug instrumentation that was causing ERR_BLOCKED_BY_CLIENT errors
* fix: Prevent unnecessary plugin tab redraws
- Add check to only update tabs when plugin list actually changes
- Increase debounce timeout to batch rapid changes
- Compare plugin IDs before updating to avoid redundant redraws
- Fix setter to check for actual changes before triggering updates
* fix: Prevent form-groups from sliding out of view when nested sections expand
- Increase margin-bottom on nested-sections for better spacing
- Add clear: both to nested-sections to ensure proper document flow
- Change overflow to visible when expanded to allow natural flow
- Add margin-bottom to expanded content
- Add spacing rules for form-groups that follow nested sections
- Add clear spacer div after nested sections
* fix: Reduce excessive debug logging in generateConfigForm
- Only log once per plugin instead of on every function call
- Prevents log spam when Alpine.js re-renders the form multiple times
- Reduces console noise from 10+ logs per plugin to 1 log per plugin
* fix: Prevent nested section content from sliding out of view when expanded
- Remove overflow-hidden from nested-section in base.html (was causing clipping)
- Add scrollIntoView to scroll expanded sections into view within modal
- Set nested-section overflow to visible to prevent content clipping
- Add min-height to nested-content to ensure proper rendering
- Wait for animation to complete before scrolling into view
* fix: Prevent form-groups from overlapping and appearing outside view
- Change nested-section overflow to hidden by default, visible when expanded
- Add :has() selector to allow overflow when content is expanded
- Ensure form-groups after nested sections have proper spacing and positioning
- Add clear: both and width: 100% to prevent overlap
- Use !important for margin-top to ensure spacing is applied
- Ensure form-groups are in normal document flow with float: none
* fix: Use JavaScript to toggle overflow instead of :has() selector
- :has() selector may not be supported in all browsers
- Use JavaScript to set overflow: visible when expanded, hidden when collapsed
- This ensures better browser compatibility while maintaining functionality
* fix: Make parent sections expand when nested sections expand
- Add updateParentNestedContentHeight() helper to recursively update parent heights
- When a nested section expands, recalculate all parent nested-content max-heights
- Ensures parent sections (like NFL) expand to accommodate expanded child sections
- Updates parent heights both on expand and collapse for proper animation
* refactor: Simplify parent section expansion using CSS max-height: none
- Remove complex recursive parent height update function
- Use CSS max-height: none when expanded to allow natural expansion
- Parent sections automatically expand because nested-content has no height constraint
- Simpler and more maintainable solution
* refactor: Remove complex recursive parent height update function
- CSS max-height: none already handles parent expansion automatically
- No need for JavaScript to manually update parent heights
- Much simpler and cleaner solution
* debug: Add instrumentation to debug auto-collapse issue
- Add logging to track toggle calls and state changes
- Add guard to prevent multiple simultaneous toggles
- Pass event object to prevent bubbling
- Improve state detection logic
- Add return false to onclick handlers
* chore: Remove debug instrumentation from toggleNestedSection
- Remove all debug logging code
- Keep functional fixes: event handling, toggle guard, improved state detection
- Code is now clean and production-ready
* fix(web): Add browser refresh note to plugin fetch errors
* refactor(text-display): Update submodule to use ScrollHelper
* fix(text-display): Fix scrolling display issue - update position in display()
* feat(text-display): Add scroll_loop option and improve scroll speed control
* debug: Add instrumentation to track plugin enabled state changes
Added debug logging to investigate why plugins appear to disable themselves:
- Track enabled state during plugin load (before/after schema merge)
- Track enabled state during plugin reload
- Track enabled state preservation during config save
- Track state reconciliation fixes
- Track enabled state updates in on_config_change
This will help identify which code path is causing plugins to disable.
* debug: Fix debug log path to work on Pi
Changed hardcoded log path to use dynamic project root detection:
- Uses LEDMATRIX_ROOT env var if set
- Falls back to detecting project root by looking for config directory
- Creates .cursor directory if it doesn't exist
- Falls back to /tmp/ledmatrix_debug.log if all else fails
- Added better error handling with logger fallback
* Remove debug instrumentation for plugin enabled state tracking
Removed all debug logging that was added to track plugin enabled state changes.
The instrumentation has been removed as requested.
* Reorganize documentation and cleanup test files
- Move documentation files to docs/ directory
- Remove obsolete test files
- Update .gitignore and README
* feat(text-display): Switch to frame-based scrolling with high FPS support
* fix(text-display): Add backward compatibility for ScrollHelper sub-pixel scrolling
* feat(scroll_helper): Add sub-pixel scrolling support for smooth movement
- Add sub-pixel interpolation using scipy (if available) or numpy fallback
- Add set_sub_pixel_scrolling() method to enable/disable feature
- Implement _get_visible_portion_subpixel() for fractional pixel positioning
- Implement _interpolate_subpixel() for linear interpolation
- Prevents pixel skipping at slow scroll speeds
- Maintains backward compatibility with integer pixel path
* fix(scroll_helper): Reset last_update_time in reset_scroll() to prevent jump-ahead
- Reset last_update_time when resetting scroll position
- Prevents large delta_time on next update after reset
- Fixes issue where scroll would immediately complete again after reset
- Ensures smooth scrolling continuation after loop reset
* fix(scroll_helper): Fix numpy broadcasting error in sub-pixel interpolation
- Add output_width parameter to _interpolate_subpixel() for variable widths
- Fix wrap-around case to use correct widths for interpolation
- Handle edge cases where source array is smaller than expected
- Prevent 'could not broadcast input array' errors in sub-pixel scrolling
- Ensure proper width matching in all interpolation paths
* feat(scroll): Add frame-based scrolling mode for smooth LED matrix movement
- Add frame_based_scrolling flag to ScrollHelper
- When enabled, moves fixed pixels per step, throttled by scroll_delay
- Eliminates time-based jitter by ignoring frame timing variations
- Provides stock-ticker-like smooth, predictable scrolling
- Update text-display plugin to use frame-based mode
This addresses stuttering issues where time-based scrolling caused
visual jitter due to frame timing variations in the main display loop.
* fix(scroll): Fix NumPy broadcasting errors in sub-pixel wrap-around
- Ensure _interpolate_subpixel always returns exactly requested width
- Handle cases where scipy.ndimage.shift produces smaller arrays
- Add padding logic for wrap-around cases when arrays are smaller than expected
- Prevents 'could not broadcast input array' errors during scrolling
* refactor(scroll): Remove sub-pixel interpolation, use high FPS integer scrolling
- Disable sub-pixel scrolling by default in ScrollHelper
- Simplify get_visible_portion to always use integer pixel positioning
- Restore frame-based scrolling logic for smooth high FPS movement
- Use high frame rate (like stock ticker) for smoothness instead of interpolation
- Reduces complexity and eliminates broadcasting errors
* fix(scroll): Prevent large pixel jumps in frame-based scrolling
- Initialize last_step_time properly to prevent huge initial jumps
- Clamp scroll_speed to max 5 pixels/frame in frame-based mode
- Prevents 60-pixel jumps when scroll_speed is misconfigured
- Simplified step calculation to avoid lag catch-up jumps
* fix(text-display): Align config schema and add validation
- Update submodule reference
- Adds warning and logging for scroll_speed config issues
* fix(scroll): Simplify frame-based scrolling to match stock ticker behavior
- Remove throttling logic from frame-based scrolling
- Move pixels every call (DisplayController's loop timing controls rate)
- Add enable_scrolling attribute to text-display plugin for high-FPS treatment
- Matches stock ticker: simple, predictable movement every frame
- Eliminates jitter from timing mismatches between DisplayController and ScrollHelper
* fix(scroll): Restore scroll_delay throttling in frame-based mode
- Restore time-based throttling using scroll_delay
- Move pixels only when scroll_delay has passed
- Handle lag catch-up with reasonable caps to prevent huge jumps
- Preserve fractional timing for smooth operation
- Now scroll_delay actually controls the scroll speed as intended
* feat(text-display): Add FPS counter logging
- Update submodule reference
- Adds FPS tracking and logging every 5 seconds
* fix(text-display): Add display-width buffer so text scrolls completely off
- Update submodule reference
- Adds end buffer to ensure text exits viewport before looping
* fix: Prevent premature game switching in SportsLive
- Set last_game_switch when games load even if current_game already exists
- Set last_game_switch when same games update but it's still 0
- Add guard to prevent switching check when last_game_switch is 0
- Fixes issue where first game shows for only ~2 seconds before switching
- Also fixes random screen flickering when games change prematurely
* feat(plugins): Add branch selection support for plugin installation
- Add optional branch parameter to install_plugin() and install_from_url() in store_manager
- Update API endpoints to accept and pass branch parameter
- Update frontend JavaScript to support branch selection in install calls
- Maintain backward compatibility - branch parameter is optional everywhere
- Falls back to default branch logic if specified branch doesn't exist
* feat(plugins): Add UI for branch selection in plugin installation
- Add branch input field in 'Install Single Plugin' section
- Add global branch input for store installations
- Update JavaScript to read branch from input fields
- Branch input applies to all store installations when specified
* feat(plugins): Change branch selection to be per-plugin instead of global
- Remove global store branch input field
- Add individual branch input field to each plugin card in store
- Add branch input to custom registry plugin cards
- Each plugin can now have its own branch specified independently
* debug: Add logging to _should_exit_dynamic
* feat(display_controller): Add universal get_cycle_duration support for all plugins
UNIVERSAL FEATURE: Any plugin can now implement get_cycle_duration() to dynamically
calculate the total time needed to show all content for a mode.
New method:
- _plugin_cycle_duration(plugin, display_mode): Queries plugin for calculated duration
Integration:
- Display controller calls plugin.get_cycle_duration(display_mode)
- Uses returned duration as target (respecting max cap)
- Falls back to cap if not provided
Benefits:
- Football plugin: Show all games (3 games × 15s = 45s total)
- Basketball plugin: Could implement same logic
- Hockey/Baseball/any sport: Universal support
- Stock ticker: Could calculate based on number of stocks
- Weather: Could calculate based on forecast days
Example plugin implementation:
Result: Plugins control their own display duration based on actual content,
creating a smooth user experience where all content is shown before switching.
* debug: Add logging to cycle duration call
* debug: Change loop exit logs to INFO level
* fix: Change cycle duration logs to INFO level
* fix: Don't exit loop on False for dynamic duration plugins
For plugins with dynamic duration enabled, keep the display loop running
even when display() returns False. This allows games to continue rotating
within the calculated duration.
The loop will only exit when:
- Cycle is complete (plugin reports all content shown)
- Max duration is reached
- Mode is changed externally
* fix(schedule): Improve display scheduling functionality
- Add GET endpoint for schedule configuration retrieval
- Fix mode switching to clean up old config keys (days/start_time/end_time)
- Improve error handling with consistent error_response() usage
- Enhance display controller schedule checking with better edge case handling
- Add validation for time formats and ensure at least one day enabled in per-day mode
- Add debug logging for schedule state changes
Fixes issues where schedule mode switching left stale config causing incorrect behavior.
* fix(install): Add cmake and ninja-build to system dependencies
Resolves h3 package build failure during first-time installation.
The h3 package (dependency of timezonefinder) requires CMake and
Ninja to build from source. Adding these build tools ensures
successful installation of all Python dependencies.
* fix: Pass display_mode in ALL loop calls to maintain sticky manager
CRITICAL FIX: Display controller was only passing display_mode on first call,
causing plugins to fall back to internal mode cycling and bypass sticky
manager logic.
Now consistently passes display_mode=active_mode on every display() call in
both high-FPS and normal loops. This ensures plugins maintain mode context
and sticky manager state throughout the entire display duration.
* feat(install): Add OS check for Raspberry Pi OS Lite (Trixie)
- Verify OS is Raspberry Pi OS (raspbian/debian)
- Require Debian 13 (Trixie) specifically
- Check for Lite version (no desktop environment)
- Exit with clear error message if requirements not met
- Provide instructions for obtaining correct OS version
* fix(web-ui): Add missing notification handlers to quick action buttons
- Added hx-on:htmx:after-request handlers to all quick action buttons in overview.html
- Added hx-ext='json-enc' for proper JSON encoding
- Added missing notification handler for reboot button in index.html
- Users will now see toast notifications when actions complete or fail
* fix(display): Ensure consistent display mode handling in all plugin calls
- Updated display controller to consistently pass display_mode in all plugin display() calls.
- This change maintains the sticky manager state and ensures plugins retain their mode context throughout the display duration.
- Addresses issues with mode cycling and improves overall display reliability.
* fix(display): Enhance display mode persistence across plugin updates
- Updated display controller to ensure display_mode is consistently maintained during plugin updates.
- This change prevents unintended mode resets and improves the reliability of display transitions.
- Addresses issues with mode persistence, ensuring a smoother user experience across all plugins.
* feat: Add Olympics countdown plugin as submodule
- Add olympics-countdown plugin submodule
- Update .gitignore to allow olympics-countdown plugin
- Plugin automatically determines next Olympics and counts down to opening/closing ceremonies
* feat(web-ui): Add checkbox-group widget support for multi-select arrays
- Add checkbox-group widget rendering in plugins_manager.js
- Update form processing to handle checkbox groups with [] naming
- Support for friendly labels via x-options in config schemas
- Update odds-ticker submodule with checkbox-group implementation
* fix(plugins): Preserve enabled state when saving plugin config from main config endpoint
When saving plugin configuration through save_main_config endpoint, the enabled
field was not preserved if missing from the form data. This caused plugins to
be automatically disabled when users saved their configuration from the plugin
manager tab.
This fix adds the same enabled state preservation logic that exists in
save_plugin_config endpoint, ensuring consistent behavior across both endpoints.
The enabled state is preserved from current config, plugin instance, or defaults
to True to prevent unexpected disabling of plugins.
* fix(git): Resolve git status timeout and exclude plugins from base project updates
- Add --untracked-files=no flag to git status for faster execution
- Increase timeout from 5s to 30s for git status operations
- Add timeout exception handling for git status and stash operations
- Filter out plugins directory from git status checks (plugins are separate repos)
- Exclude plugins from stash operations using :!plugins pathspec
- Apply same fixes to plugin store manager update operations
* feat(plugins): Add granular scroll speed control to odds-ticker and leaderboard plugins
- Add display object to both plugins' config schemas with scroll_speed and scroll_delay
- Enable frame-based scrolling mode for precise FPS control (100 FPS for leaderboard)
- Add set_scroll_speed() and set_scroll_delay() methods to both plugins
- Maintain backward compatibility with scroll_pixels_per_second config
- Leaderboard plugin now explicitly sets target_fps to 100 for high-performance scrolling
* fix(scroll): Correct dynamic duration calculation for frame-based scrolling
- Fix calculate_dynamic_duration() to properly handle frame-based scrolling mode
- Convert scroll_speed from pixels/frame to pixels/second when in frame-based mode
- Prevents incorrect duration calculations (e.g., 2609s instead of 52s)
- Affects all plugins using ScrollHelper: odds-ticker, leaderboard, stocks, text-display
- Add debug logging to show scroll mode and effective speed
* Remove version logic from plugin system, use git commits instead
- Remove version parameter from install_plugin() method
- Rename fetch_latest_versions to fetch_commit_info throughout codebase
- Remove version fields from plugins.json registry (versions, latest_version, download_url_template)
- Remove version logging from plugin manager
- Update web UI to use fetch_commit_info parameter
- Update .gitignore to ignore all plugin folders (remove whitelist exceptions)
- Remove plugin directories from git index (plugins now installed via plugin store only)
Plugins now always install latest commit from default branch. Version fields
replaced with git commit SHA and commit dates. System uses git-based approach
for all plugin metadata.
* feat(plugins): Normalize all plugins as git submodules
- Convert all 18 plugins to git submodules for uniform management
- Add submodules for: baseball-scoreboard, christmas-countdown, football-scoreboard, hockey-scoreboard, ledmatrix-flights, ledmatrix-leaderboard, ledmatrix-music, ledmatrix-stocks, ledmatrix-weather, static-image
- Re-initialize mqtt-notifications as proper submodule
- Update .gitignore to allow all plugin submodules
- Add normalize_plugin_submodules.sh script for future plugin management
All plugins with GitHub repositories are now managed as git submodules,
ensuring consistent version control and easier updates.
* refactor(repository): Reorganize scripts and files into organized directory structure
- Move installation scripts to scripts/install/ (except first_time_install.sh)
- Move development scripts to scripts/dev/
- Move utility scripts to scripts/utils/
- Move systemd service files to systemd/
- Keep first_time_install.sh, start_display.sh, stop_display.sh in root
- Update all path references in scripts, documentation, and service files
- Add README.md files to new directories explaining their purpose
- Remove empty tools/ directory (contents moved to scripts/dev/)
- Add .gitkeep to data/ directory
* fix(scripts): Fix PROJECT_DIR path in start_web_conditionally.py after move to scripts/utils/
* fix(scripts): Fix PROJECT_DIR/PROJECT_ROOT path resolution in moved scripts
- Fix wifi_monitor_daemon.py to use project root instead of scripts/utils/
- Fix shell scripts in scripts/ to correctly resolve project root (go up one more level)
- Fix scripts in scripts/fix_perms/ to correctly resolve project root
- Update diagnose_web_interface.sh to reference moved start_web_conditionally.py path
All scripts now correctly determine project root after reorganization.
* fix(install): Update first_time_install.sh to detect and update service files with old paths
- Check for old paths in service files and reinstall if needed
- Always reinstall main service (install_service.sh is idempotent)
- This ensures existing installations get updated paths after reorganization
* fix(install): Update install_service.sh message to indicate it updates existing services
* fix(wifi): Enable WiFi scan to work when AP mode is active
- Temporarily disable AP mode during network scanning
- Automatically re-enable AP mode after scan completes
- Add proper error handling with try/finally to ensure AP mode restoration
- Add user notification when AP mode is temporarily disabled
- Improve error messages for common scanning failures
- Add timing delays for interface mode switching
* fix(wifi): Fix network parsing to handle frequency with 'MHz' suffix
- Strip 'MHz' suffix from frequency field before float conversion
- Add better error logging for parsing failures
- Fixes issue where all networks were silently skipped due to ValueError
* debug(wifi): Add console logging and Alpine.js reactivity fixes for network display
- Add console.log statements to debug network scanning
- Add x-effect to force Alpine.js reactivity updates
- Add unique keys to x-for template
- Add debug display showing network count
- Improve error handling and user feedback
* fix(wifi): Manually update select options instead of using Alpine.js x-for
- Replace Alpine.js x-for template with manual DOM manipulation
- Add updateSelectOptions() method to directly update select dropdown
- This fixes issue where networks weren't appearing in dropdown
- Alpine.js x-for inside select elements can be unreliable
* feat(web-ui): Add patternProperties support for dynamic key-value pairs
- Add UI support for patternProperties objects (custom_feeds, feed_logo_map)
- Implement key-value pair editor with add/remove functionality
- Add JavaScript functions for managing dynamic key-value pairs
- Update form submission to handle patternProperties JSON data
- Enable easy configuration of feed_logo_map in web UI
* chore: Update ledmatrix-news submodule to latest commit
* fix(plugins): Handle arrays of objects in config normalization
Fix configuration validation failure for static-image plugin by adding
recursive normalization support for arrays of objects. The normalize_config_values
function now properly handles arrays containing objects (like image_config.images)
by recursively normalizing each object in the array using the items schema properties.
This resolves the 'configuration validation failed' error when saving static
image plugin configuration with multiple images.
* fix(plugins): Handle union types in config normalization and form generation
Fix configuration validation for fields with union types like ['integer', 'null'].
The normalization function now properly handles:
- Union types in top-level fields (e.g., random_seed: ['integer', 'null'])
- Union types in array items
- Empty string to None conversion for nullable fields
- Form generation and submission for union types
This resolves validation errors when saving plugin configs with nullable
integer/number fields (e.g., rotation_settings.random_seed in static-image plugin).
Also improves UX by:
- Adding placeholder text for nullable fields explaining empty = use default
- Properly handling empty values in form submission for union types
* fix(plugins): Improve union type normalization with better edge case handling
Enhanced normalization for union types like ['integer', 'null']:
- Better handling of whitespace in string values
- More robust empty string to None conversion
- Fallback to None when conversion fails and null is allowed
- Added debug logging for troubleshooting normalization issues
- Improved handling of nested object fields with union types
This should resolve remaining validation errors for nullable integer/number
fields in nested objects (e.g., rotation_settings.random_seed).
* chore: Add ledmatrix-news plugin to .gitignore exceptions
* Fix web interface service script path in install_service.sh
- Updated ExecStart path from start_web_conditionally.py to scripts/utils/start_web_conditionally.py
- Updated diagnose_web_ui.sh to check for correct script path
- Fixes issue where web UI service fails to start due to incorrect script path
* Fix nested configuration section headers not expanding
Fixed toggleNestedSection function to properly calculate scrollHeight when
expanding nested configuration sections. The issue occurred when sections
started with display:none - the scrollHeight was being measured before the
browser had a chance to lay out the element, resulting in a value of 0.
Changes:
- Added setTimeout to delay scrollHeight measurement until after layout
- Added overflow handling during animations to prevent content jumping
- Added fallback for edge cases where scrollHeight might still be 0
- Set maxHeight to 'none' after expansion completes for natural growth
- Updated function in both base.html and plugins_manager.js
This fix applies to all plugins with nested configuration sections, including:
- Hockey/Football/Basketball/Baseball/Soccer scoreboards (customization, global sections)
- All plugins with transition, display, and other nested configuration objects
Fixes configuration header expansion issues across all plugins.
* Fix syntax error in first_time_install.sh step 8.5
Added missing 'fi' statement to close the if block in the WiFi monitor
service installation section. This resolves the 'unexpected end of file'
error that occurred at line 1385 during step 8.5.
* Fix WiFi UI: Display correct SSID and accurate signal strength
- Fix WiFi network selection dropdown not showing available networks
- Replace manual DOM manipulation with Alpine.js x-for directive
- Add fallback watcher to ensure select updates reactively
- Fix WiFi status display showing netplan connection name instead of SSID
- Query actual SSID from device properties (802-11-wireless.ssid)
- Add fallback methods to get SSID from active WiFi connection list
- Improve signal strength accuracy
- Get signal directly from device properties (WIFI.SIGNAL)
- Add multiple fallback methods for robust signal retrieval
- Ensure signal percentage is accurate and up-to-date
* Improve WiFi connection UI and error handling
- Fix connect button disabled condition to check both selectedSSID and manualSSID
- Improve error handling to display actual server error messages from 400 responses
- Add step-by-step labels (Step 1, Step 2, Step 3) to clarify connection workflow
- Add visual feedback showing selected network in blue highlight box
- Improve password field labeling with helpful instructions
- Add auto-clear logic between dropdown and manual SSID entry
- Enhance backend validation with better error messages and logging
- Trim SSID whitespace before processing to prevent validation errors
* Add WiFi disconnect functionality for AP mode testing
- Add disconnect_from_network() method to WiFiManager
- Disconnects from current WiFi network using nmcli
- Automatically triggers AP mode check if auto_enable_ap_mode is enabled
- Returns success/error status with descriptive messages
- Add /api/v3/wifi/disconnect API endpoint
- POST endpoint to disconnect from current WiFi network
- Includes proper error handling and logging
- Add disconnect button to WiFi status section
- Only visible when connected to a network
- Red styling to indicate disconnection action
- Shows 'Disconnecting...' state during operation
- Automatically refreshes status after disconnect
- Integrates with AP mode auto-enable functionality
- When disconnected, automatically enables AP mode if configured
- Perfect for testing captive portal and AP mode features
* Add explicit handling for broken pipe errors during plugin dependency installation
- Catch BrokenPipeError and OSError (errno 32) explicitly in all dependency installation methods
- Add clear error messages explaining network interruption or buffer overflow causes
- Improves error handling in store_manager, plugin_loader, and plugin_manager
- Helps diagnose 'Errno 32 Broken Pipe' errors during pip install operations
* Add WiFi permissions configuration script and integrate into first-time install
- Create configure_wifi_permissions.sh script
- Configures passwordless sudo for nmcli commands
- Configures PolicyKit rules for NetworkManager control
- Fixes 'Not Authorized to control Networking' error
- Allows web interface to connect/disconnect WiFi without password prompts
- Integrate WiFi permissions configuration into first_time_install.sh
- Added as Step 10.1 after passwordless sudo configuration
- Runs automatically during first-time installation
- Ensures WiFi management works out of the box
- Resolves authorization errors when connecting/disconnecting WiFi networks
- NetworkManager requires both sudo and PolicyKit permissions
- Script configures both automatically for seamless WiFi management
* Add WiFi status LED message display integration
- Integrate WiFi status messages from wifi_manager into display_controller
- WiFi status messages interrupt normal rotation (but respect on-demand)
- Priority: on-demand > wifi-status > live-priority > normal rotation
- Safe implementation with comprehensive error handling
- Automatic cleanup of expired/corrupted status files
- Word-wrapping for long messages (max 2 lines)
- Centered text display with small font
- Non-intrusive: all errors are caught and logged, never crash controller
* Fix display loop issues: reduce log spam and handle missing plugins
- Change _should_exit_dynamic logging from INFO to DEBUG to reduce log spam
in tight loops (every 8ms) that was causing high CPU usage
- Fix display loop not running when manager_to_display is None
- Add explicit check to set display_result=False when no plugin manager found
- Fix logic bug where manager_to_display was overwritten after circuit breaker skip
- Ensure proper mode rotation when plugins have no content or aren't found
* Add debug logging to diagnose display loop stuck issue
* Change debug logs to INFO level to diagnose display loop stuck
* Add schedule activation logging and ensure display is blanked when inactive
- Add clear INFO-level log message when schedule makes display inactive
- Track previous display state to detect schedule transitions
- Clear display when schedule makes it inactive to ensure blank screen
(prevents showing initialization screen when schedule kicks in)
- Initialize _was_display_active state tracking in __init__
* Fix indentation errors in schedule state tracking
* Add rotation between hostname and IP address every 10 seconds
- Added _get_local_ip() method to detect device IP address
- Implemented automatic rotation between hostname and IP every 10 seconds
- Enhanced logging to include both hostname and IP in initialization
- Updated get_info() to expose device_ip and current_display_mode
* Add WiFi connection failsafe system
- Save original connection before attempting new connection
- Automatically restore original connection if new connection fails
- Enable AP mode as last resort if restoration fails
- Enhanced connection verification with multiple attempts
- Verify correct SSID (not just 'connected' status)
- Better error handling and exception recovery
- Prevents Pi from becoming unresponsive on connection failure
- Always ensures device remains accessible via original WiFi or AP mode
* feat(web): Improve web UI startup speed and fix cache permissions
- Defer plugin discovery until first API request (removed from startup)
- Add lazy loading to operation queue, state manager, and operation history
- Defer health monitor initialization until first request
- Fix cache directory permission issue:
- Add systemd CacheDirectory feature for automatic cache dir creation
- Add manual cache directory creation in install script as fallback
- Improve cache manager logging (reduce alarming warnings)
- Fix syntax errors in wifi_manager.py (unclosed try blocks)
These changes significantly improve web UI startup time, especially with many
plugins installed, while maintaining full backward compatibility.
* feat(plugins): Improve GitHub token pop-up UX and combine warning/settings
- Fix visibility toggle to handle inline styles properly
- Remove redundant inline styles from HTML elements
- Combine warning banner and settings panel into unified component
- Add loading states to save/load token buttons
- Improve error handling with better user feedback
- Add token format validation (ghp_ or github_pat_ prefix)
- Auto-refresh GitHub auth status after saving token
- Hide warning banner when settings panel opens
- Clear input field after successful save for security
This creates a smoother UX flow where clicking 'Configure Token'
transitions from warning directly to configuration form.
* fix(wifi): Prevent WiFi radio disabling during AP mode disable
- Make NetworkManager restart conditional (only for hostapd mode)
- Add enhanced WiFi radio enable with retry and verification logic
- Add connectivity safety check before NetworkManager restart
- Ensure WiFi radio enabled after all AP mode disable operations
- Fix indentation bug in dnsmasq backup restoration logic
- Add pre-connection WiFi radio check for safety
Fixes issue where WiFi radio was being disabled when disabling AP mode,
especially when connected via Ethernet, making it impossible to enable
WiFi from the web UI.
* fix(plugin-templates): Fix unreachable fallback to expired cache in update() method
The exception handler in update() checked the cached variable, which would
always be None or falsy at that point. If fresh cached data existed, the
method returned early. If cached data was expired, it was filtered out by
max_age constraint. The fix retrieves cached data again in the exception
handler with a very large max_age (1 year) to effectively bypass expiration
check and allow fallback to expired data when fetch fails.
* fix(plugin-templates): Resolve plugin_id mismatch in test template setUp method
* feat(plugins): Standardize manifest version fields schema
- Consolidate version fields to use consistent naming:
- compatible_versions: array of semver ranges (required)
- min_ledmatrix_version: string (optional)
- max_ledmatrix_version: string (optional)
- versions[].ledmatrix_min_version: renamed from ledmatrix_min
- Add manifest schema validation (schema/manifest_schema.json)
- Update store_manager to validate version fields and schema
- Update template and all documentation examples to use standardized fields
- Add deprecation warnings for ledmatrix_version and ledmatrix_min fields
* fix(templates): Update plugin README template script path to correct location
* docs(plugin): Resolve conflicting version management guidance in .cursorrules
* chore(.gitignore): Consolidate plugin exclusion patterns
Remove unnecessary !plugins/*/.git pattern and consolidate duplicate
negations by keeping only trailing-slash directory exclusions.
* docs: Add language specifiers to code blocks in STATIC_IMAGE_MULTI_UPLOAD_PLAN.md
* fix(templates): Remove api_key from config.json example in plugin README template
Remove api_key field from config.json example to prevent credential leakage.
API keys should only be stored in config_secrets.json. Added clarifying note
about proper credential storage.
* docs(README): Add plugin installation and migration information
- Add plugin installation instructions via web interface and GitHub URL
- Add plugin migration guide for users upgrading from old managers
- Improve plugin documentation for new users
* docs(readme): Update donation links and add Discord acknowledgment
* docs: Add comprehensive API references and consolidate documentation
- Add API_REFERENCE.md with complete REST API documentation (50+ endpoints)
- Add PLUGIN_API_REFERENCE.md documenting Display Manager, Cache Manager, and Plugin Manager APIs
- Add ADVANCED_PLUGIN_DEVELOPMENT.md with advanced patterns and examples
- Add DEVELOPER_QUICK_REFERENCE.md for quick developer reference
- Consolidate plugin configuration docs into single PLUGIN_CONFIGURATION_GUIDE.md
- Archive completed implementation summaries to docs/archive/
- Enhance PLUGIN_DEVELOPMENT_GUIDE.md with API links and 3rd party submission guidelines
- Update docs/README.md with new API reference sections
- Update root README.md with documentation links
* fix(install): Fix IP detection and network diagnostics after fresh install
- Fix web-ui-info plugin IP detection to handle no internet, AP mode, and network state changes
- Replace socket-based detection with robust interface scanning using hostname -I and ip addr
- Add AP mode detection returning 192.168.4.1 when AP mode is active
- Add periodic IP refresh every 30 seconds to handle network state changes
- Improve network diagnostics in first_time_install.sh showing actual IPs, WiFi status, and AP mode
- Add WiFi connection check in WiFi monitor installation with warnings
- Enhance web service startup logging to show accessible IP addresses
- Update README with network troubleshooting section and fix port references (5001->5000)
Fixes issue where display showed incorrect IP (127.0.11:5000) and users couldn't access web UI after fresh install.
* chore: Add GitHub sponsor button configuration
* fix(wifi): Fix aggressive AP mode enabling and improve WiFi detection
Critical fixes:
- Change auto_enable_ap_mode default from True to False (manual enable only)
- Fixes issue where Pi would disconnect from network after code updates
- Matches documented behavior (was incorrectly defaulting to True in code)
Improvements:
- Add grace period: require 3 consecutive disconnected checks (90s) before enabling AP mode
- Prevents AP mode from enabling on transient network hiccups
- Improve WiFi status detection with retry logic and better nmcli parsing
- Enhanced logging for debugging WiFi connection issues
- Better handling of WiFi device detection (works with any wlan device)
This prevents the WiFi monitor from aggressively enabling AP mode and
disconnecting the Pi from the network when there are brief network issues
or during system initialization.
* fix(wifi): Revert auto_enable_ap_mode default to True with grace period protection
Change default back to True for auto_enable_ap_mode while keeping the grace
period protection that prevents interrupting valid WiFi connections.
- Default auto_enable_ap_mode back to True (useful for setup scenarios)
- Grace period (3 consecutive checks = 90s) prevents false positives
- Improved WiFi detection with retry logic ensures accurate status
- AP mode will auto-enable when truly disconnected, but won't interrupt
valid connections due to transient detection issues
* fix(news): Update submodule reference for manifest fix
Update ledmatrix-news submodule to include the fixed manifest.json with
required entry_point and class_name fields.
* fix(news): Update submodule reference with validate_config addition
Update ledmatrix-news submodule to include validate_config method for
proper configuration validation.
* feat: Add of-the-day plugin as git submodule
- Add ledmatrix-of-the-day plugin as git submodule
- Rename submodule path from plugins/of-the-day to plugins/ledmatrix-of-the-day to match repository naming convention
- Update .gitignore to allow ledmatrix-of-the-day submodule
- Plugin includes fixes for display rendering and web UI configuration support
* fix(wifi): Make AP mode open network and fix WiFi page loading in AP mode
AP Mode Changes:
- Remove password requirement from AP mode (open network for easier setup)
- Update hostapd config to create open network (no WPA/WPA2)
- Update nmcli hotspot to create open network (no password parameter)
WiFi Page Loading Fixes:
- Download local copies of HTMX and Alpine.js libraries
- Auto-detect AP mode (192.168.4.x) and use local JS files instead of CDN
- Auto-open WiFi tab when accessing via AP mode IP
- Add fallback loading if HTMX fails to load
- Ensures WiFi setup page works in AP mode without internet access
This fixes the issue where the WiFi page wouldn't load on iPhone when
accessing via AP mode (192.168.4.1:5000) because CDN resources couldn't
be fetched without internet connectivity.
* feat(wifi): Add explicit network switching support with clean disconnection
WiFi Manager Improvements:
- Explicitly disconnect from current network before connecting to a new one
- Add skip_ap_check parameter to disconnect_from_network() to prevent AP mode
from activating during network switches
- Check if already connected to target network to avoid unnecessary work
- Improved logging for network switching operations
Web UI Improvements:
- Detect and display network switching status in UI
- Show 'Switching from [old] to [new]...' message when switching networks
- Enhanced status reloading after connection (multiple checks at 2s, 5s, 10s)
- Better user feedback during network transitions
This ensures clean network switching without AP mode interruptions and
provides clear feedback to users when changing WiFi networks.
* fix(web-ui): Add fallback content loading when HTMX fails to load
Problem:
- After recent updates, web UI showed navigation and CPU status but main
content tabs never loaded
- Content tabs depend on HTMX's 'revealed' trigger to load
- If HTMX failed to load or initialize, content would never appear
Solutions:
- Enhanced HTMX loading verification with timeout checks
- Added fallback direct fetch for overview tab if HTMX fails
- Added automatic tab content loading when tabs change
- Added loadTabContent() method to manually trigger content loading
- Added global 'htmx-load-failed' event for error handling
- Automatic retry after 5 seconds if HTMX isn't available
- Better error messages and console logging for debugging
This ensures the web UI loads content even if HTMX has issues,
providing graceful degradation and better user experience.
* feat(web-ui): Add support for plugin custom HTML widgets and static file serving
- Add x-widget: custom-html support in config schema generation
- Add loadCustomHtmlWidget() function to load HTML from plugin directories
- Add /api/v3/plugins/<plugin_id>/static/<file_path> endpoint for serving plugin static files
- Enhance execute_plugin_action() to pass params via stdin as JSON for scripts
- Add JSON output parsing for script action responses
These changes enable plugins to provide custom UI components while keeping
all functionality plugin-scoped. Used by of-the-day plugin for file management.
* fix(web-ui): Resolve Alpine.js initialization errors
- Prevent Alpine.js from auto-initializing before app() function is defined
- Add deferLoadingAlpine to ensure proper initialization order
- Make app() function globally available via window.app
- Fix 'app is not defined' and 'activeTab is not defined' errors
- Remove duplicate Alpine.start() calls that caused double initialization warnings
* fix(web-ui): Fix IndentationError in api_v3.py OAuth flow
- Fix indentation in if action_def.get('oauth_flow') block
- Properly indent try/except block and all nested code
- Resolves IndentationError that prevented web interface from starting
* fix(web-ui): Fix SyntaxError in api_v3.py else block
- Fix indentation of OAuth flow code inside else block
- Properly indent else block for simple script execution
- Resolves SyntaxError at line 3458 that prevented web interface from starting
* fix(web-ui): Restructure OAuth flow check to fix SyntaxError
- Move OAuth flow check before script execution in else block
- Remove unreachable code that was causing syntax error
- OAuth check now happens first, then falls back to script execution
- Resolves SyntaxError at line 3458
* fix(web-ui): Define app() function in head for Alpine.js initialization
- Define minimal app() function in head before Alpine.js loads
- Ensures app() is available when Alpine initializes
- Full implementation in body enhances/replaces the stub
- Fixes 'app is not defined' and 'activeTab is not defined' errors
* fix(web-ui): Ensure plugin tabs load when full app() implementation is available
- Update stub init() to detect and use full implementation when available
- Ensure full implementation properly replaces stub methods
- Call init() after merging to load plugins and set up watchers
- Fixes issue where installed plugins weren't showing in navigation bar
* fix(web-ui): Prevent 'Cannot redefine property' error for installedPlugins
- Check if window.installedPlugins property already exists before defining
- Make property configurable to allow redefinition if needed
- Add _initialized flag to prevent multiple init() calls
- Fixes TypeError when stub tries to enhance with full implementation
* fix(web-ui): Fix variable redeclaration errors in logs tab
- Replace let/const declarations with window properties to avoid redeclaration
- Use window._logsEventSource, window._allLogs, etc. to persist across HTMX reloads
- Clean up existing event source before reinitializing
- Remove and re-add event listeners to prevent duplicates
- Fixes 'Identifier has already been declared' error when accessing logs tab multiple times
* feat(web-ui): Add support for additionalProperties object rendering
- Add handler for objects with additionalProperties containing object schemas
- Render dynamic category controls with enable/disable toggles
- Display category metadata (display name, data file path)
- Used by of-the-day plugin for category management
* fix(wifi): Ensure AP mode hotspot is always open (no password)
Problem:
- LEDMatrix-Setup WiFi AP was still asking for password despite code changes
- Existing hotspot connections with passwords weren't being fully cleaned up
- NetworkManager might reuse old connection profiles with passwords
Solutions:
- More thorough cleanup: Delete all hotspot-related connections, not just known names
- Verification: Check if hotspot has password after creation
- Automatic fix: Remove password and restart connection if security is detected
- Better logging: Log when password is detected and removed
This ensures the AP mode hotspot is always open for easy setup access,
even if there were previously saved connections with passwords.
* fix(wifi): Improve network switching reliability and device state handling
Problem:
- Pi failing to switch WiFi networks via web UI
- Connection attempts happening before device is ready
- Disconnect not fully completing before new connection attempt
- Connection name lookup issues when SSID doesn't match connection name
Solutions:
- Improved disconnect logic: Disconnect specific connection first, then device
- Device state verification: Wait for device to be ready (disconnected/unavailable) before connecting
- Better connection lookup: Search by SSID, not just connection name
- Increased wait times: 2 seconds for disconnect to complete
- State checking before activating existing connections
- Enhanced error handling and logging throughout
This ensures network switching works reliably by properly managing device
state transitions and using correct connection identifiers.
* debug(web-ui): Add debug logging for custom HTML widget loading
- Add console logging to track widget generation
- Improve error messages with missing configuration details
- Help diagnose why file manager widget may not be appearing
* fix(web-ui): Fix [object Object] display in categories field
- Add type checking to ensure category values are strings before rendering
- Safely extract data_file and display_name properties
- Prevent object coercion issues in category display
* perf(web-ui): Optimize plugin loading in navigation bar
- Reduce stub init timeout from 100ms to 10ms for faster enhancement
- Change full implementation merge from 50ms setTimeout to requestAnimationFrame
- Add direct plugin loading in stub while waiting for full implementation
- Skip plugin reload in full implementation if already loaded by stub
- Significantly improves plugin tab loading speed in navigation bar
* feat(web-ui): Adapt file-upload widget for JSON files in of-the-day plugin
- Add specialized JSON upload/delete endpoints for of-the-day plugin
- Modify file-upload widget to support JSON files (file_type: json)
- Render JSON files with file-code icon instead of image preview
- Show entry count for JSON files
- Store files in plugins/ledmatrix-of-the-day/of_the_day/ directory
- Automatically update categories config when files are uploaded/deleted
- Populate uploaded_files array from categories on form load
- Remove custom HTML widget, use standard file-upload widget instead
* fix(web-ui): Add working updatePluginTabs to stub for immediate plugin tab rendering
- Stub's updatePluginTabs was empty, preventing tabs from showing
- Add basic implementation that creates plugin tabs in navigation bar
- Ensures plugin tabs appear immediately when plugins load, even before full implementation merges
- Fixes issue where plugin navigation bar wasn't working
* feat(api): Populate uploaded_files and categories from disk for of-the-day plugin
- Scan of_the_day directory for existing JSON files when loading config
- Populate uploaded_files array from files on disk
- Populate categories from files on disk if not in config
- Categories default to disabled, user can enable them
- Ensures existing JSON files (word_of_the_day.json, slovenian_word_of_the_day.json) appear in UI
* fix(api): Improve category merging logic for of-the-day plugin
- Preserve existing category enabled state when merging with files from disk
- Ensure all JSON files from disk appear in categories section
- Categories from files default to disabled, preserving user choices
- Properly merge existing config with scanned files
* fix(wifi): More aggressive password removal for AP mode hotspot
Problem:
- LEDMatrix-Setup network still asking for password despite previous fixes
- NetworkManager may add default security settings to hotspots
- Existing connections with passwords may not be fully cleaned up
Solutions:
- Always remove ALL security settings after creating hotspot (not just when detected)
- Remove multiple security settings: key-mgmt, psk, wep-key, auth-alg
- Verify security was removed and recreate connection if verification fails
- Improved cleanup: Delete connections by SSID match, not just by name
- Disconnect connections before deleting them
- Always restart connection after removing security to apply changes
- Better logging for debugging
This ensures the AP mode hotspot is always open, even if NetworkManager
tries to add default security settings.
* perf(web): Optimize web interface performance and fix JavaScript errors
- Add resource hints (preconnect, dns-prefetch) for CDN resources to reduce DNS lookup delays
- Fix duplicate response parsing bug in loadPluginConfig that was parsing JSON twice
- Replace direct fetch() calls with PluginAPI.getInstalledPlugins() to leverage caching and throttling
- Fix Alpine.js function availability issues with defensive checks and $nextTick
- Enhance request deduplication with debug logging and statistics
- Add response caching headers for static assets and API responses
- Add performance monitoring utilities with detailed metrics
Fixes console errors for loadPluginConfig and generateConfigForm not being defined.
Reduces duplicate API calls to /api/v3/plugins/installed endpoint.
Improves initial page load time with resource hints and optimized JavaScript loading.
* perf(web-ui): optimize CSS for Raspberry Pi performance
- Remove backdrop-filter blur from modal-backdrop
- Remove box-shadow transitions (use transform/opacity only)
- Remove button ::before pseudo-element animation
- Simplify skeleton loader (gradient to opacity pulse)
- Optimize transition utility (specific properties, not 'all')
- Improve color contrast for WCAG AA compliance
- Add CSS containment to cards, plugin-cards, modals
- Remove unused CSS classes (duration-300, divider, divider-light)
- Remove duplicate spacing utility classes
All animations now GPU-accelerated (transform/opacity only).
Optimized for low-powered Raspberry Pi devices.
* fix(web): Resolve ReferenceError for getInstalledPluginsSafe in v3 stub initialization
Move getInstalledPluginsSafe() function definition before the app() stub code that uses it. The function was previously defined at line 3756 but was being called at line 849 during Alpine.js initialization, causing a ReferenceError when loadInstalledPluginsDirectly() attempted to load plugins before the full implementation was ready.
* fix(web): Resolve TypeError for installedPlugins.map in plugin loading
Fix PluginAPI.getInstalledPlugins() to properly extract plugins array from API response structure. The API returns {status: 'success', data: {plugins: [...]}}, but the method was returning response.data (the object) instead of response.data.plugins (the array).
Changes:
- api_client.js: Extract plugins array from response.data.plugins
- plugins_manager.js: Add defensive array checks and handle array return value correctly
- base.html: Add defensive check in getInstalledPluginsSafe() to ensure plugins is always an array
This prevents 'installedPlugins.map is not a function' errors when loading plugins.
* style(web-ui): Enhance navigation bar styling for better readability
- Improve contrast: Change inactive tab text from gray-500 to gray-700
- Add gradient background and thicker border for active tabs
- Enhance hover states with background highlights
- Add smooth transitions using GPU-accelerated properties
- Update all navigation buttons (system tabs and plugin tabs)
- Add updatePluginTabStates() method for dynamic tab state management
All changes are CSS-only with zero performance overhead.
* fix(web-ui): Optimize plugin loading and reduce initialization errors
- Make generateConfigForm accessible to inline Alpine components via parent scope
- Consolidate plugin initialization to prevent duplicate API calls
- Fix script execution from HTMX-loaded content by extracting scripts before DOM insertion
- Add request deduplication to loadInstalledPlugins() to prevent concurrent requests
- Improve Alpine component initialization with proper guards and fallbacks
This eliminates 'generateConfigForm is not defined' errors and reduces plugin
API calls from 3-4 duplicate calls to 1 per page load, significantly improving
page load performance.
* fix(web-ui): Add guard check for generateConfigForm to prevent Alpine errors
Add typeof check in x-show to prevent Alpine from evaluating generateConfigForm
before the component methods are fully initialized. This eliminates the
'generateConfigForm is not defined' error that was occurring during component
initialization.
* fix(web-ui): Fix try-catch block structure in script execution code
Correct the nesting of try-catch block inside the if statement for script execution.
The catch block was incorrectly placed after the else clause, causing a syntax error.
* fix(web-ui): Escape quotes in querySelector to avoid HTML attribute conflicts
Change double quotes to single quotes in the CSS selector to prevent conflicts
with HTML attribute parsing when the x-data expression is embedded.
* style(web): Improve button text readability in Quick Actions section
* fix(web): Resolve Alpine.js expression errors in plugin configuration component
- Capture plugin from parent scope into component data to fix parsing errors
- Update all plugin references to use this.plugin in component methods
- Fix x-init to properly call loadPluginConfig method
- Resolves 'Uncaught ReferenceError' for isOnDemandLoading, onDemandLastUpdated, and other component properties
* fix(web): Fix remaining Alpine.js scope issues in plugin configuration
- Use this.generateConfigForm in typeof checks and method calls
- Fix form submission to use this.plugin.id
- Use $root. prefix for parent scope function calls (refreshPlugin, updatePlugin, etc.)
- Fix confirm dialog string interpolation
- Ensures all component methods and properties are properly scoped
* fix(web): Add this. prefix to all Alpine.js component property references
- Fix all template expressions to use this. prefix for component properties
- Update isOnDemandLoading, onDemandLastUpdated, onDemandRefreshing references
- Update onDemandStatusClass, onDemandStatusText, onDemandServiceClass, onDemandServiceText
- Update disableRunButton, canStopOnDemand, showEnableHint, loading references
- Ensures Alpine.js can properly resolve all component getters and properties
* fix(web): Resolve Alpine.js expression errors in plugin configuration
- Move complex x-data object to pluginConfigData() function for better parsing
- Fix all template expressions to use this.plugin instead of plugin
- Add this. prefix to all method calls in event handlers
- Fix duplicate x-on:click attribute on uninstall button
- Add proper loading state management in loadPluginConfig method
This resolves the 'Invalid or unexpected token' and 'Uncaught ReferenceError'
errors in the browser console.
* fix(web): Fix plugin undefined errors in Alpine.js plugin configuration
- Change x-data initialization to capture plugin from loop scope first
- Use Object.assign in x-init to merge pluginConfigData properties
- Add safety check in pluginConfigData function for undefined plugins
- Ensure plugin is available before accessing properties in expressions
This resolves the 'Cannot read properties of undefined' errors by ensuring
the plugin object is properly captured from the x-for loop scope before
any template expressions try to access it.
* style(web): Make Quick Actions button text styling consistent
- Update Start Display, Stop Display, and Reboot System buttons
- Change from text-sm font-medium to text-base font-semibold
- All Quick Actions buttons now have consistent bold, larger text
- Matches the styling of Update Code, Restart Display Service, and Restart Web Service buttons
* fix(wifi): Properly handle AP mode disable during WiFi connection
- Check return value of disable_ap_mode() before proceeding with connection
- Add verification loop to ensure AP mode is actually disabled
- Increase wait time to 5 seconds for NetworkManager restart stabilization
- Return clear error messages if AP mode cannot be disabled
- Prevents connection failures when switching networks from web UI or AP mode
This fixes the issue where WiFi network switching would fail silently when
AP mode disable failed, leaving the system in an inconsistent state.
* fix(web): Handle API response errors in plugin configuration loading
- Add null/undefined checks before accessing API response status
- Set fallback defaults when API responses don't have status 'success'
- Add error handling for batch API requests with fallback to individual requests
- Add .catch() handlers to individual fetch calls to prevent unhandled rejections
- Add console warnings to help debug API response failures
- Fix applies to both main loadPluginConfig and PluginConfigHelpers.loadPluginConfig
This fixes the issue where plugin configuration sections would get stuck
showing the loading animation when API responses failed or returned error status.
* fix(web): Fix Alpine.js reactivity for plugin config by using direct x-data
Changed from Object.assign pattern to direct x-data assignment to ensure
Alpine.js properly tracks reactive properties. The previous approach used
Object.assign to merge properties into the component after initialization,
which caused Alpine to not detect changes to config/schema properties.
The fix uses pluginConfigData(plugin) directly as x-data, ensuring all
properties including config, schema, loading, etc. are reactive from
component initialization.
* fix(web): Ensure plugin variable is captured in x-data scope
Use spread operator to merge pluginConfigData properties while explicitly
capturing the plugin variable from outer x-for scope. This fixes undefined
plugin errors when Alpine evaluates the component data.
* fix(web): Use $data for Alpine.js reactivity when merging plugin config
Use Object.assign with Alpine's $data reactive proxy instead of this to
ensure added properties are properly reactive. This fixes the issue where
plugin variable scoping from x-for wasn't accessible in x-data expressions.
* fix(web): Remove incorrect 'this.' prefix in Alpine.js template expressions
Alpine.js template expressions (x-show, x-html, x-text, x-on) use the
component data as the implicit context, so 'this.' prefix is incorrect.
In template expressions, 'this' refers to the DOM element, not the
component data.
Changes:
- Replace 'this.plugin.' with 'plugin.' in all template expressions (19 instances)
- Replace 'this.loading' with 'loading' in x-show directives
- Replace 'this.generateConfigForm' with 'generateConfigForm' in x-show/x-html
- Replace 'this.savePluginConfig' with 'savePluginConfig' in x-on:submit
- Replace 'this.config/schema/webUiActions' with direct property access
- Use '$data.loadPluginConfig' in x-init for explicit method call
Note: 'this.' is still correct inside JavaScript method definitions within
pluginConfigData() function since those run with proper object context.
* fix(web): Prevent infinite recursion in plugin config methods
Add 'parent !== this' check to loadPluginConfig, generateConfigForm, and
savePluginConfig methods in pluginConfigData to prevent infinite recursion
when the component tries to delegate to a parent that resolves to itself.
This fixes the 'Maximum call stack size exceeded' error that occurred when
the nested Alpine component's $root reference resolved to a component that
had the same delegating methods via Object.assign.
* fix(web): Resolve infinite recursion in plugin config by calling $root directly
The previous implementation had delegating methods (generateConfigForm,
savePluginConfig) in pluginConfigData that tried to call parent.method(),
but the parent detection via getParentApp() was causing circular calls
because multiple components had the same methods.
Changes:
- Template now calls $root.generateConfigForm() and $root.savePluginConfig()
directly instead of going through nested component delegation
- Removed delegating generateConfigForm and savePluginConfig from pluginConfigData
- Removed getParentApp() helper that was enabling the circular calls
- Simplified loadPluginConfig to use PluginConfigHelpers directly
This fixes the 'Maximum call stack size exceeded' error when rendering
plugin configuration forms.
* fix(web): Use window.PluginConfigHelpers instead of $root for plugin config
The $root magic variable in Alpine.js doesn't correctly reference the
app() component's data scope from nested x-data contexts. This causes
generateConfigForm and savePluginConfig to be undefined.
Changed to use window.PluginConfigHelpers which has explicit logic to
find and use the app component's methods.
* fix(web): Use direct x-data initialization for plugin config reactivity
Changed from Object.assign($data, pluginConfigData(plugin)) to
x-data="pluginConfigData(plugin)" to ensure Alpine.js properly
tracks reactivity for all plugin config properties. This fixes
the issue where all plugin tabs were showing the same config.
* refactor(web): Implement server-side plugin config rendering with HTMX
Major architectural improvement to plugin configuration management:
- Add server-side Jinja2 template for plugin config forms
(web_interface/templates/v3/partials/plugin_config.html)
- Add Flask route to serve plugin config partials on-demand
- Replace complex client-side form generation with HTMX lazy loading
- Add Alpine.js store for centralized plugin state management
- Mark old pluginConfigData and PluginConfigHelpers as deprecated
Benefits:
- Lazy loading: configs only load when tab is accessed
- Server-side rendering: reduces client-side complexity
- Better performance: especially on Raspberry Pi
- Cleaner code: Jinja2 macros replace JS string templates
- More maintainable: form logic in one place (server)
The old client-side code is preserved for backwards compatibility
but is no longer used by the main plugin configuration UI.
* fix(web): Trigger HTMX manually after Alpine renders plugin tabs
HTMX processes attributes at page load time, before Alpine.js
renders dynamic content. Changed from :hx-get attribute to
x-init with htmx.ajax() to properly trigger the request after
the element is rendered.
* fix(web): Remove duplicate 'enabled' toggle from plugin config form
The 'enabled' field was appearing twice in plugin configuration:
1. Header toggle (quick action, uses HTMX)
2. Configuration form (from schema, requires save)
Now only the header toggle is shown, avoiding user confusion.
The 'enabled' key is explicitly skipped when rendering schema properties.
* perf(web): Optimize plugin manager with request caching and init guards
Major performance improvements to plugins_manager.js:
1. Request Deduplication & Caching
- Added pluginLoadCache with 3-second TTL
- Subsequent calls return cached data instead of making API requests
- In-flight request deduplication prevents parallel duplicate fetches
- Added refreshInstalledPlugins() for explicit force-refresh
2. Initialization Guards
- Added pluginsInitialized flag to prevent multiple initializePlugins() calls
- Added _eventDelegationSetup guard on container to prevent duplicate listeners
- Added _listenerSetup guards on search/category inputs
3. Debug Logging Control
- Added PLUGIN_DEBUG flag (localStorage.setItem('pluginDebug', 'true'))
- Most console.log calls now use pluginLog() which only logs when debug enabled
- Reduces console noise from ~150 logs to ~10 in production
Expected improvements:
- API calls reduced from 6+ to 2 on page load
- Event listeners no longer duplicated
- Cleaner console output
- Faster perceived performance
* fix(web): Handle missing search elements in searchPluginStore
The searchPluginStore function was failing silently when called before
the plugin-search and plugin-category elements existed in the DOM.
This caused the plugin store to never load.
Now safely checks if elements exist before accessing their values.
* fix(web): Ensure plugin store loads via pluginManager.searchPluginStore
- Exposed searchPluginStore on window.pluginManager for easier access
- Updated base.html to fallback to pluginManager.searchPluginStore
- Added logging when loading plugin store
* fix(web): Expose searchPluginStore from inside the IIFE
The function was defined inside the IIFE but only exposed after the IIFE
ended, where the function was out of scope. Now exposed immediately after
definition inside the IIFE.
* fix(web): Add cache-busting version to plugins_manager.js URL
Static JS files were being aggressively cached, preventing updates
from being loaded by browsers.
* fix(web): Fix pluginLog reference error outside IIFE
pluginLog is defined inside the IIFE, so use _PLUGIN_DEBUG_EARLY and
console.log directly for code outside the IIFE.
* chore(web): Update plugins_manager.js cache version
* fix(web): Defer plugin store render when grid not ready
Instead of showing an error when plugin-store-grid doesn't exist,
store plugins in window.__pendingStorePlugins for later rendering
when the tab loads (consistent with how installed plugins work).
* chore: Bump JS cache version
* fix(web): Restore enabledBool variable in plugin render
Variable was removed during debug logging optimization but was still
being used in the template string for toggle switch rendering.
* fix(ui): Add header and improve categories section rendering
- Add proper header (h4) to categories section with label
- Add debug logging to diagnose categories field rendering
- Improve additionalProperties condition check readability
* fix(ui): Improve additionalProperties condition check
- Explicitly exclude objects with properties to avoid conflicts
- Ensure categories section is properly detected and rendered
- Categories should show as header with toggles, not text box
* fix(web-ui): Fix JSON parsing errors and default value loading for plugin configs
- Fix JSON parsing errors when saving file upload fields by properly unescaping HTML entities
- Merge config with schema defaults when loading plugin config so form shows default values
- Improve default value handling in form generation for nested objects and arrays
- Add better error handling for malformed JSON in file upload fields
* fix(plugins): Return plugins array from getInstalledPlugins() instead of data object
Fixed PluginAPI.getInstalledPlugins() to return response.data.plugins (array)
instead of response.data (object). This was preventing window.installedPlugins
from being set correctly, which caused plugin configuration tabs to not appear
and prevented users from saving plugin configurations via the web UI.
The fix ensures that:
- window.installedPlugins is properly populated with plugin array
- Plugin tabs are created automatically on page load
- Configuration forms and save buttons are rendered correctly
- Save functionality works as expected
* fix(api): Support form data submission for plugin config saves
The HTMX form submissions use application/x-www-form-urlencoded format
instead of JSON. This update allows the /api/v3/plugins/config POST
endpoint to accept both formats:
- JSON: plugin_id and config in request body (existing behavior)
- Form data: plugin_id from query string, config fields from form
Added _parse_form_value helper to properly convert form strings to
appropriate Python types (bool, int, float, JSON arrays/objects).
* debug: Add form data logging to diagnose config save issue
* fix(web): Re-discover plugins before loading config partial
The plugin config partial was returning 'not found' for plugins
because the plugin manifests weren't loaded. The installed plugins
API was working because it calls discover_plugins() first.
Changes:
- Add discover_plugins() call in _load_plugin_config_partial when
plugin info is not found on first try
- Remove debug logging from form data handling
* fix(web): Comprehensive plugin config save improvements
SWEEPING FIX for plugin configuration saving issues:
1. Form data now MERGES with existing config instead of replacing
- Partial form submissions (missing fields) no longer wipe out
existing config values
- Fixes plugins with complex schemas (football, clock, etc.)
2. Improved nested value handling with _set_nested_value helper
- Correctly handles deeply nested structures like customization
- Properly merges when intermediate objects already exist
3. Better JSON parsing for arrays
- RGB color arrays like [255, 0, 0] now parse correctly
- Parse JSON before trying number conversion
4. Bump cache version to force JS reload
* fix(web): Add early stubs for updatePlugin and uninstallPlugin
Ensures these functions are available immediately when the page loads,
even before the full IIFE executes. Provides immediate user feedback
and makes API calls directly.
This fixes the 'Update button does not work' issue by ensuring the
function is always defined and callable.
* fix(web): Support form data in toggle endpoint
The toggle endpoint now accepts both JSON and HTMX form submissions.
Also updated the plugin config template to send the enabled state
via hx-vals when the checkbox changes.
Fixes: 415 Unsupported Media Type error when toggling plugins
* fix(web): Prevent config duplication when toggling plugins
Changed handleToggleResponse to update UI in place instead of
refreshing the entire config partial, which was causing duplication.
Also improved refreshPluginConfig with proper container targeting
and concurrent refresh prevention (though it's no longer needed
for toggles since we update in place).
* fix(api): Schema-aware form value parsing for plugin configs
Major fix for plugin config saving issues:
1. Load schema BEFORE processing form data to enable type-aware parsing
2. New _parse_form_value_with_schema() function that:
- Converts comma-separated strings to arrays when schema says 'array'
- Parses JSON strings for arrays/objects
- Handles empty strings for arrays (returns [] instead of None)
- Uses schema to determine correct number types
3. Post-processing to ensure None arrays get converted to empty arrays
4. Proper handling of nested object fields
Fixes validation errors:
- 'category_order': Expected type array, got str
- 'categories': Expected type object, got str
- 'uploaded_files': Expected type array, got NoneType
- RGB color arrays: Expected type array, got str
* fix(web): Make plugin config handlers idempotent and remove scripts from HTMX partials
CRITICAL FIX for script redeclaration errors:
1. Removed all <script> tags from plugin_config.html partial
- Scripts were being re-executed on every HTMX swap
- Caused 'Identifier already declared' errors
2. Moved all handler functions to base.html with idempotent initialization
- Added window.__pluginConfigHandlersInitialized guard
- Functions only initialized once, even if script runs multiple times
- All state stored on window object (e.g., window.pluginConfigRefreshInProgress)
3. Enhanced error logging:
- Client-side: Logs form payload, response status, and parsed error details
- Server-side: Logs raw form data and parsed config on validation failures
4. Functions moved to window scope:
- toggleSection
- handleConfigSave (with detailed error logging)
- handleToggleResponse (updates UI in place, no refresh)
- handlePluginUpdate
- refreshPluginConfig (with duplicate prevention)
- runPluginOnDemand
- stopOnDemand
- executePluginAction
This ensures HTMX-swapped fragments only contain HTML, and all
scripts run once in the base layout.
* fix(api): Filter config to only schema-defined fields before validation
When merging with existing_config, fields not in the plugin's schema
(like high_performance_transitions, transition, dynamic_duration)
were being preserved, causing validation failures when
additionalProperties is false.
Add _filter_config_by_schema() function to recursively filter config
to only include fields defined in the schema before validation.
This fixes validation errors like:
- 'Additional properties are not allowed (high_performance_transitions, transition were unexpected)'
* fix(web): Improve update plugin error handling and support form data
1. Enhanced updatePlugin JavaScript function:
- Validates pluginId before sending request
- Checks response.ok before parsing JSON
- Better error logging with request/response details
- Handles both successful and error responses properly
2. Update endpoint now supports both JSON and form data:
- Similar to config endpoint, accepts plugin_id from query string or form
- Better error messages and debug logging
3. Prevent duplicate function definitions:
- Second updatePlugin definition checks if improved version exists
- Both definitions now have consistent error handling
Fixes: 400 BAD REQUEST 'Request body must be valid JSON' error
* fix(web): Show correct 'update' message instead of 'save' for plugin updates
The handlePluginUpdate function now:
1. Checks actual HTTP status code (not just event.detail.successful)
2. Parses JSON response to get server's actual message
3. Replaces 'save' with 'update' if message incorrectly says 'save'
Fixes: Update button showing 'saved successfully' instead of
'updated successfully'
* fix(web): Execute plugin updates immediately instead of queuing
Plugin updates are now executed directly (synchronously) instead of
being queued for async processing. This provides immediate feedback
to users about whether the update succeeded or failed.
Updates are fast git pull operations, so they don't need async
processing. The operation queue is reserved for longer operations
like install/uninstall.
Fixes: Update button not actually updating plugins (operations were
queued but users didn't see results)
* fix(web): Ensure toggleSection function is always available for collapsible headers
Moved toggleSection outside the initialization guard block so it's
always defined, even if the plugin config handlers have already been
initialized. This ensures collapsible sections in plugin config forms
work correctly.
Added debug logging to help diagnose if sections/icons aren't found.
Fixes: Collapsible headers in plugin config schema not collapsing
* fix(web): Improve toggleSection to explicitly show/hide collapsible content
Changed from classList.toggle() to explicit add/remove of 'hidden' class
based on current state. This ensures the content visibility is properly
controlled when collapsing/expanding sections.
Added better error checking and state detection for more reliable
collapsible section behavior.
* fix(web): Load plugin tabs on page load instead of waiting for plugin manager tab click
The stub's loadInstalledPlugins was an empty function, so plugin tabs
weren't loading until the plugin manager tab was clicked. Now the stub
implementation:
1. Tries to use global window.loadInstalledPlugins if available
2. Falls back to window.pluginManager.loadInstalledPlugins
3. Finally falls back to direct loading via loadInstalledPluginsDirectly
4. Always updates tabs after loading plugins
This ensures plugin navigation tabs are available immediately on page load.
Fixes: Plugin tabs only loading after clicking plugin manager tab
* fix(web): Ensure plugin navigation tabs load on any page regardless of active tab
Multiple improvements to ensure plugin tabs are always visible:
1. Stub's loadInstalledPluginsDirectly now waits for DOM to be ready
before updating tabs, using requestAnimationFrame for proper timing
2. Stub's init() now has a retry mechanism that periodically checks
if plugins have been loaded by plugins_manager.js and updates tabs
accordingly (checks for 2 seconds)
3. Full implementation's init() now properly handles async plugin loading
and ensures tabs are updated after loading completes, checking
window.installedPlugins first before attempting to load
4. Both stub and full implementation ensure tabs update using $nextTick
to wait for Alpine.js rendering cycle
This ensures plugin navigation tabs are visible immediately when the
page loads, regardless of whether the user is on overview, plugin manager,
or any other tab.
Fixes: Plugin tabs only appearing after clicking plugin manager tab
* fix(web): Fix restart display button not working
The initPluginsPage function was returning early before event listeners
were set up, making all the event listener code unreachable. Moved the
return statement to after all event listeners are attached.
This fixes the restart display button and all other buttons in the
plugin manager (refresh plugins, update all, search, etc.) that depend
on event listeners being set up.
Fixes: Restart Display button not working in plugin manager
* fix(web-ui): Improve categories field rendering for of-the-day plugin
- Add more explicit condition checking for additionalProperties objects
- Add debug logging specifically for categories field
- Add fallback handler for objects that don't match special cases (render as JSON textarea)
- Ensure categories section displays correctly with toggle cards instead of plain text
* fix(install): Prevent following broken symlinks during file ownership setup
- Add -P flag to find commands to prevent following symlinks when traversing
- Add -h flag to chown to operate on symlinks themselves rather than targets
- Exclude scripts/dev/plugins directory which contains development symlinks
- Fixes error when chown tries to dereference broken symlinks with extra LEDMatrix in path
* fix(scroll): Ensure scroll completes fully before switching displays
- Add display_width to total scroll distance calculation
- Scroll now continues until content is completely off screen
- Update scroll completion check to use total_scroll_width + display_width
- Prevents scroll from being cut off mid-way when switching to next display
* fix(install): Remove unsupported -P flag from find commands
- Remove -P flag which is not supported on all find versions
- Keep -h flag on chown to operate on symlinks themselves
- Change to {} \; syntax for better error handling
- Add error suppression to continue on broken symlinks
- Exclude scripts/dev/plugins directory to prevent traversal into broken symlinks
* docs(wifi): Add trailing newline to WiFi AP failover setup guide
* fix(web): Suppress non-critical socket errors and fix WiFi permissions script
- Add error filtering in web interface to suppress harmless client disconnection errors
- Downgrade 'No route to host' and broken pipe errors from ERROR to DEBUG level
- Fix WiFi permissions script to use mktemp instead of manual temp file creation
- Add cleanup trap to ensure temp files are removed on script exit
- Resolves permission denied errors when creating temp files during installation
* fix(web): Ensure plugin navigation tabs load on any page by dispatching events
The issue was that when plugins_manager.js loaded and called
loadInstalledPlugins(), it would set window.installedPlugins but the
Alpine.js component wouldn't know to update its tabs unless the plugin
manager tab was clicked.
Changes:
1. loadInstalledPlugins() now always dispatches a 'pluginsUpdated' event
when it sets window.installedPlugins, not just when plugin IDs change
2. renderInstalledPlugins() also dispatches the event and always updates
window.installedPlugins for reactivity
3. Cached plugin data also dispatches the event when returned
The Alpine component already listens for the 'pluginsUpdated' event in
its init() method, so tabs will now update immediately when plugins are
loaded, regardless of which tab is active.
Fixes: Plugin navigation tabs only loading after clicking plugin manager tab
* fix(web): Improve input field contrast in plugin configuration forms
Changed input backgrounds from bg-gray-800 to bg-gray-900 (darker) to
ensure high contrast with white text. Added placeholder:text-gray-400
for better placeholder text visibility.
Updated in both server-side template (plugin_config.html) and client-side
form generation (plugins_manager.js):
- Number inputs
- Text inputs
- Array inputs (comma-separated)
- Select dropdowns
- Textareas (JSON objects)
- Fallback inputs without schema
This ensures all form inputs have high contrast white text on dark
background, making them clearly visible and readable.
Fixes: White text on white background in plugin config inputs
* fix(web): Change plugin config input text from white to black
Changed all input fields in plugin configuration forms to use black text
on white background instead of white text on dark background for better
readability and standard form appearance.
Updated:
- Input backgrounds: bg-gray-900 -> bg-white
- Text color: text-white -> text-black
- Placeholder color: text-gray-400 -> text-gray-500
Applied to both server-side template and client-side form generation
for all input types (number, text, select, textarea).
* fix(web): Ensure toggleSection function is available for plugin config collapsible sections
Moved toggleSection function definition to an early script block so it's
available immediately when HTMX loads plugin configuration content. The
function was previously defined later in the page which could cause it
to not be accessible when inline onclick handlers try to call it.
The function toggles the 'hidden' class on collapsible section content
divs and rotates the chevron icon between right (collapsed) and down
(expanded) states.
Fixes: Plugin configuration section headers not collapsing/expanding
* fix(web): Fix collapsible section toggle to properly hide/show content
Updated toggleSection function to explicitly set display style in addition
to toggling the hidden class. This ensures the content is properly hidden
even if CSS specificity or other styles might interfere with just the
hidden class.
The function now:
- Checks both the hidden class and computed display style
- Explicitly sets display: '' when showing and display: 'none' when hiding
- Rotates chevron icon between right (collapsed) and down (expanded)
This ensures collapsible sections in plugin configuration forms properly
hide and show their content when the header is clicked.
Fixes: Collapsible section headers rotate chevron but don't hide content
* fix(web): Fix collapsible section toggle to work on first click
Simplified the toggle logic to rely primarily on the 'hidden' class check
rather than mixing it with computed display styles. When hiding, we now
remove any inline display style to let Tailwind's 'hidden' class properly
control the display property.
This ensures sections respond correctly on the first click, whether they're
starting in a collapsed or expanded state.
Fixes: Sections requiring 2 clicks to collapse
* fix(web): Ensure collapsible sections start collapsed by default
Added explicit display: none style to nested content divs in plugin config
template to ensure they start collapsed. The hidden class should handle this,
but adding the inline style ensures sections are definitely collapsed on
initial page load.
Sections now:
- Start collapsed (hidden) with chevron pointing right
- Expand when clicked (chevron points down)
- Collapse when clicked again (chevron points right)
This ensures a consistent collapsed initial state across all plugin
configuration sections.
* fix(web): Fix collapsible section toggle to properly collapse on second click
Fixed the toggle logic to explicitly set display: block when showing and
display: none when hiding, rather than clearing the display style. This
ensures the section state is properly tracked and the toggle works correctly
on both expand and collapse clicks.
The function now:
- When hidden: removes hidden class, sets display: block, chevron down
- When visible: adds hidden class, sets display: none, chevron right
This fixes the issue where sections would expand but not collapse again.
Fixes: Sections not collapsing on second click
* feat(web): Ensure plugin navigation tabs load automatically on any page
Implemented comprehensive solution to ensure plugin navigation tabs load
automatically without requiring a visit to the plugin manager page:
1. Global event listener for 'pluginsUpdated' - works even if Alpine isn't
ready yet, updates tabs directly when plugins_manager.js loads plugins
2. Enhanced stub's loadInstalledPluginsDirectly():
- Sets window.installedPlugins after loading
- Dispatches 'pluginsUpdated' event for global listener
- Adds console logging for debugging
3. Event listener in stub's init() method:
- Listens for 'pluginsUpdated' events
- Updates component state and tabs when events fire
4. Fallback timer:
- If plugins_manager.js hasn't loaded after 2 seconds, fetches
plugins directly via API
- Ensures tabs appear even if plugins_manager.js fails
5. Improved checkAndUpdateTabs():
- Better logging
- Fallback to direct fetch after timeout
6. Enhanced logging throughout plugin loading flow for debugging
This ensures plugin tabs are visible immediately on page load, regardless
of which tab is active or when plugins_manager.js loads.
Fixes: Plugin navigation tabs only loading after visiting plugin manager
* fix(web): Improve plugin tabs update logging and ensure immediate execution
Enhanced logging in updatePluginTabs() and _doUpdatePluginTabs() to help
debug why tabs aren't appearing. Changed debounce behavior to execute
immediately on first call to ensure tabs appear quickly.
Added detailed console logging with [FULL] prefix to track:
- When updatePluginTabs() is called
- When _doUpdatePluginTabs() executes
- DOM element availability
- Tab creation process
- Final tab count
This will help identify if tabs are being created but not visible, or if
the update function isn't being called at all.
Fixes: Plugin tabs loading but not visible in navigation bar
* fix(web): Prevent duplicate plugin tab updates and clearing
Added debouncing and duplicate prevention to stub's updatePluginTabs() to
prevent tabs from being cleared and re-added multiple times. Also checks
if tabs already match before clearing them.
Changes:
1. Debounce stub's updatePluginTabs() with 100ms delay
2. Check if existing tabs match current plugin list before clearing
3. Global event listener only triggers full implementation's updatePluginTabs
4. Stub's event listener only works in stub mode (before enhancement)
This prevents the issue where tabs were being cleared and re-added
multiple times in rapid succession, which could leave tabs empty.
Fixes: Plugin tabs being cleared and not re-added properly
* fix(web): Fix plugin tabs not rendering when plugins are loaded
Fixed _doUpdatePluginTabs() to properly use component's installedPlugins
instead of checking window.installedPlugins first. Also fixed the 'unchanged'
check to not skip when both lists are empty (first load scenario).
Changes:
1. Check component's installedPlugins first (most up-to-date)
2. Only skip update if plugins exist AND match (don't skip empty lists)
3. Retry if no plugins found (in case they're still loading)
4. Ensure window.installedPlugins is set when loading directly
5. Better logging to show which plugin source is being used
This ensures tabs are rendered when plugins are loaded, even on first page load.
Fixes: Plugin tabs not being drawn despite plugins being loaded
* fix(config): Fix array field parsing and validation for plugin config forms
- Added logic to detect and combine indexed array fields (text_color.0, text_color.1, etc.)
- Fixed array fields incorrectly stored as dicts with numeric keys
- Improved handling of comma-separated array values from form submissions
- Ensures array fields meet minItems requirements before validation
- Resolves 400 BAD REQUEST errors when saving plugin config with RGB color arrays
* fix(config): Improve array field handling and secrets error handling
- Use schema defaults when array fields don't meet minItems requirement
- Add debug logging for array field parsing
- Improve error handling for secrets file writes
- Fix arrays stored as dicts with numeric keys conversion
- Better handling of incomplete array values from form submissions
* fix(config): Convert array elements to correct types (numbers not strings)
- Fix array element type conversion when converting dicts to arrays
- Ensure RGB color arrays have integer elements, not strings
- Apply type conversion for both nested and top-level array fields
- Fixes validation errors: 'Expected type number, got str'
* fix(config): Fix array fields showing 'none' when value is null
- Handle None/null values in array field templates properly
- Use schema defaults when array values are None/null
- Fix applies to both Jinja2 template and JavaScript form generation
- Resolves issue where stock ticker plugin shows 'none' instead of default values
* fix(config): Add novalidate to plugin config form to prevent HTML5 validation blocking saves
- Prevents browser HTML5 validation from blocking form submission
- Allows custom validation logic to handle form data properly
- Fixes issue where save button appears unclickable due to invalid form controls
- Resolves problems with plugins like clock-simple that have nested/array fields
* feat(config): Add helpful form validation with detailed error messages
- Keep HTML5 validation enabled (removed novalidate) to prevent broken configs
- Add validatePluginConfigForm function that shows which fields fail and why
- Automatically expands collapsed sections containing invalid fields
- Focuses first invalid field and scrolls to it
- Shows user-friendly error messages with field names and specific issues
- Prevents form submission until all fields are valid
* fix(schema): Remove core properties from required array during validation
- Core properties (enabled, display_duration, live_priority) are system-managed
- SchemaManager now removes them from required array after injection
- Added default values for core properties (enabled=True, display_duration=15, live_priority=False)
- Updated generate_default_config() to ensure live_priority has default
- Resolves 186 validation issues, reducing to 3 non-blocking warnings (98.4% reduction)
- All 19 of 20 plugins now pass validation without errors
Documentation:
- Created docs/PLUGIN_CONFIG_CORE_PROPERTIES.md explaining core property handling
- Updated existing docs to reflect core property behavior
- Removed temporary audit files and scripts
* fix(ui): Improve button text contrast on white backgrounds
- Changed Screenshot button text from text-gray-700 to text-gray-900
- Added global CSS rule to ensure all buttons with white backgrounds use dark text (text-gray-900) for better readability
- Fixes contrast issues where light text on light backgrounds was illegible
* fix(ui): Add explicit text color to form-control inputs
- Added color: #111827 to .form-control class to ensure dark text on white backgrounds
- Fixes issue where input fields had white text on white background after button contrast fix
- Ensures all form inputs are readable with proper contrast
* docs: Update impact explanation and plugin config documentation
* docs: Improve documentation and fix template inconsistencies
- Add migration guide for script path reorganization (scripts moved to scripts/install/ and scripts/fix_perms/)
- Add breaking changes section to README with migration guidance
- Fix config template: set plugins_directory to 'plugins' to match actual plugin locations
- Fix test template: replace Jinja2 placeholders with plain text to match other templates
- Fix markdown linting: add language identifiers to code blocks (python, text, javascript)
- Update permission guide: document setgid bit (0o2775) for directory modes
- Fix example JSON: pin dependency versions and fix compatible_versions range
- Improve readability: reduce repetition in IMPACT_EXPLANATION.md
* feat(web): Make v3 interface production-ready for local deployment
- Phase 2: Real Service Integration
- Replace sample data with real psutil system monitoring (CPU, memory, disk, temp, uptime)
- Integrate display controller to read from /tmp/led_matrix_preview.png snapshot
- Scan assets/fonts directory and extract font metadata with freetype
- Phase 1: Security & Input Validation
- Add input validation module with URL, file upload, and config sanitization
- Add optional CSRF protection (gracefully degrades if flask-wtf missing)
- Add rate limiting (lenient for local use, prevents accidental abuse)
- Add file upload validation to font upload endpoint
- Phase 3: Error Handling
- Add global error handlers for 404, 500, and unhandled exceptions
- All endpoints have comprehensive try/except blocks
- Phase 4: Monitoring & Observability
- Add structured logging with JSON format support
- Add request logging middleware (tracks method, path, status, duration, IP)
- Add /api/v3/health endpoint with service status checks
- Phase 5: Performance & Caching
- Add in-memory caching system (separate module to avoid circular imports)
- Cache font catalog (5 minute TTL)
- Cache system status (10 second TTL)
- Invalidate cache on config changes
- All changes are non-blocking with graceful error handling
- Optional dependencies (flask-wtf, flask-limiter) degrade gracefully
- All imports protected with try/except blocks
- Verified compilation and import tests pass
* docs: Fix caching pattern logic flaw and merge conflict resolution plan
- Fix Basic Caching Pattern: Replace broken stale cache fallback with correct pattern
- Re-fetch cache with large max_age (31536000) in except block instead of checking already-falsy cached variable
- Fixes both instances in ADVANCED_PLUGIN_DEVELOPMENT.md
- Matches correct pattern from manager.py.template
- Fix MERGE_CONFLICT_RESOLUTION_PLAN.md merge direction
- Correct Step 1 to checkout main and merge plugins into it (not vice versa)
- Update commit message to reflect 'Merge plugins into main' direction
- Fixes workflow to match documented plugins → main merge
---------
Co-authored-by: Chuck <chuck@example.com>
5657 lines
245 KiB
JavaScript
5657 lines
245 KiB
JavaScript
// Define critical functions immediately so they're available before any HTML is rendered
|
|
// Debug logging controlled by localStorage.setItem('pluginDebug', 'true')
|
|
const _PLUGIN_DEBUG_EARLY = typeof localStorage !== 'undefined' && localStorage.getItem('pluginDebug') === 'true';
|
|
if (_PLUGIN_DEBUG_EARLY) console.log('[PLUGINS SCRIPT] Defining configurePlugin and togglePlugin at top level...');
|
|
|
|
// Expose on-demand functions early as stubs (will be replaced when IIFE runs)
|
|
window.openOnDemandModal = function(pluginId) {
|
|
console.warn('openOnDemandModal called before initialization, waiting...');
|
|
// Wait for the real function to be available
|
|
let attempts = 0;
|
|
const maxAttempts = 50; // 2.5 seconds
|
|
const checkInterval = setInterval(() => {
|
|
attempts++;
|
|
if (window.__openOnDemandModalImpl) {
|
|
clearInterval(checkInterval);
|
|
window.__openOnDemandModalImpl(pluginId);
|
|
} else if (attempts >= maxAttempts) {
|
|
clearInterval(checkInterval);
|
|
console.error('openOnDemandModal not available after waiting');
|
|
if (typeof showNotification === 'function') {
|
|
showNotification('On-demand modal unavailable. Please refresh the page.', 'error');
|
|
}
|
|
}
|
|
}, 50);
|
|
};
|
|
|
|
window.requestOnDemandStop = function({ stopService = false } = {}) {
|
|
console.warn('requestOnDemandStop called before initialization, waiting...');
|
|
// Wait for the real function to be available
|
|
let attempts = 0;
|
|
const maxAttempts = 50; // 2.5 seconds
|
|
const checkInterval = setInterval(() => {
|
|
attempts++;
|
|
if (window.__requestOnDemandStopImpl) {
|
|
clearInterval(checkInterval);
|
|
return window.__requestOnDemandStopImpl({ stopService });
|
|
} else if (attempts >= maxAttempts) {
|
|
clearInterval(checkInterval);
|
|
console.error('requestOnDemandStop not available after waiting');
|
|
if (typeof showNotification === 'function') {
|
|
showNotification('On-demand stop unavailable. Please refresh the page.', 'error');
|
|
}
|
|
return Promise.reject(new Error('Function not available'));
|
|
}
|
|
}, 50);
|
|
return Promise.resolve();
|
|
};
|
|
|
|
// Define updatePlugin early as a stub to ensure it's always available
|
|
window.updatePlugin = window.updatePlugin || function(pluginId) {
|
|
if (_PLUGIN_DEBUG_EARLY) console.log('[PLUGINS STUB] updatePlugin called for', pluginId);
|
|
|
|
// Validate pluginId
|
|
if (!pluginId || typeof pluginId !== 'string') {
|
|
console.error('Invalid pluginId:', pluginId);
|
|
if (typeof showNotification === 'function') {
|
|
showNotification('Invalid plugin ID', 'error');
|
|
}
|
|
return Promise.reject(new Error('Invalid plugin ID'));
|
|
}
|
|
|
|
// Show immediate feedback
|
|
if (typeof showNotification === 'function') {
|
|
showNotification(`Updating ${pluginId}...`, 'info');
|
|
}
|
|
|
|
// Prepare request body
|
|
const requestBody = { plugin_id: pluginId };
|
|
const requestBodyJson = JSON.stringify(requestBody);
|
|
|
|
console.log('[UPDATE] Sending request:', { url: '/api/v3/plugins/update', body: requestBodyJson });
|
|
|
|
// Make the API call directly
|
|
return fetch('/api/v3/plugins/update', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Accept': 'application/json'
|
|
},
|
|
body: requestBodyJson
|
|
})
|
|
.then(async response => {
|
|
// Check if response is OK before parsing
|
|
if (!response.ok) {
|
|
// Try to parse error response
|
|
let errorData;
|
|
try {
|
|
const text = await response.text();
|
|
console.error('[UPDATE] Error response:', { status: response.status, statusText: response.statusText, body: text });
|
|
errorData = JSON.parse(text);
|
|
} catch (e) {
|
|
errorData = { message: `Server error: ${response.status} ${response.statusText}` };
|
|
}
|
|
|
|
if (typeof showNotification === 'function') {
|
|
showNotification(errorData.message || `Update failed: ${response.status}`, 'error');
|
|
}
|
|
throw new Error(errorData.message || `Update failed: ${response.status}`);
|
|
}
|
|
|
|
// Parse successful response
|
|
return response.json();
|
|
})
|
|
.then(data => {
|
|
if (typeof showNotification === 'function') {
|
|
showNotification(data.message || 'Update initiated', data.status || 'info');
|
|
}
|
|
// Refresh installed plugins if available
|
|
if (typeof loadInstalledPlugins === 'function') {
|
|
loadInstalledPlugins();
|
|
} else if (typeof window.pluginManager?.loadInstalledPlugins === 'function') {
|
|
window.pluginManager.loadInstalledPlugins();
|
|
}
|
|
return data;
|
|
})
|
|
.catch(error => {
|
|
console.error('[UPDATE] Error updating plugin:', error);
|
|
if (typeof showNotification === 'function') {
|
|
showNotification('Error updating plugin: ' + error.message, 'error');
|
|
}
|
|
throw error;
|
|
});
|
|
};
|
|
|
|
// Define uninstallPlugin early as a stub
|
|
window.uninstallPlugin = window.uninstallPlugin || function(pluginId) {
|
|
if (_PLUGIN_DEBUG_EARLY) console.log('[PLUGINS STUB] uninstallPlugin called for', pluginId);
|
|
|
|
if (!confirm(`Are you sure you want to uninstall ${pluginId}?`)) {
|
|
return Promise.resolve({ cancelled: true });
|
|
}
|
|
|
|
if (typeof showNotification === 'function') {
|
|
showNotification(`Uninstalling ${pluginId}...`, 'info');
|
|
}
|
|
|
|
return fetch('/api/v3/plugins/uninstall', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ plugin_id: pluginId })
|
|
})
|
|
.then(response => response.json())
|
|
.then(data => {
|
|
if (typeof showNotification === 'function') {
|
|
showNotification(data.message || 'Uninstall initiated', data.status || 'info');
|
|
}
|
|
// Refresh installed plugins if available
|
|
if (typeof loadInstalledPlugins === 'function') {
|
|
loadInstalledPlugins();
|
|
} else if (typeof window.pluginManager?.loadInstalledPlugins === 'function') {
|
|
window.pluginManager.loadInstalledPlugins();
|
|
}
|
|
return data;
|
|
})
|
|
.catch(error => {
|
|
console.error('Error uninstalling plugin:', error);
|
|
if (typeof showNotification === 'function') {
|
|
showNotification('Error uninstalling plugin: ' + error.message, 'error');
|
|
}
|
|
throw error;
|
|
});
|
|
};
|
|
|
|
// Cleanup orphaned modals from previous executions to prevent duplicates when moving to body
|
|
try {
|
|
const existingModals = document.querySelectorAll('#plugin-config-modal');
|
|
if (existingModals.length > 0) {
|
|
existingModals.forEach(el => {
|
|
// Only remove modals that were moved to body (orphaned from previous loads)
|
|
// The new modal in the current content should be inside a container, not direct body child
|
|
if (el.parentElement === document.body) {
|
|
console.log('[PLUGINS SCRIPT] Cleaning up orphaned plugin modal');
|
|
el.remove();
|
|
}
|
|
});
|
|
}
|
|
} catch (e) {
|
|
console.warn('[PLUGINS SCRIPT] Error cleaning up modals:', e);
|
|
}
|
|
|
|
// Track pending render data for when DOM isn't ready yet
|
|
window.__pendingInstalledPlugins = window.__pendingInstalledPlugins || null;
|
|
window.__pendingStorePlugins = window.__pendingStorePlugins || null;
|
|
window.__pluginDomReady = window.__pluginDomReady || false;
|
|
|
|
// Set up global event delegation for plugin actions (works even before plugins are loaded)
|
|
(function setupGlobalEventDelegation() {
|
|
// Use document-level delegation so it works for dynamically added content
|
|
const handleGlobalPluginAction = function(event) {
|
|
// Only handle if it's a plugin action
|
|
const button = event.target.closest('button[data-action][data-plugin-id]') ||
|
|
event.target.closest('input[data-action][data-plugin-id]');
|
|
if (!button) return;
|
|
|
|
const action = button.getAttribute('data-action');
|
|
const pluginId = button.getAttribute('data-plugin-id');
|
|
|
|
// For toggle and configure, ensure functions are available
|
|
if (action === 'toggle' || action === 'configure') {
|
|
const funcName = action === 'toggle' ? 'togglePlugin' : 'configurePlugin';
|
|
if (!window[funcName] || typeof window[funcName] !== 'function') {
|
|
// Prevent default and stop propagation immediately to avoid double handling
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
|
|
console.warn(`[GLOBAL DELEGATION] ${funcName} not available yet, waiting...`);
|
|
|
|
// Capture state synchronously from plugin data (source of truth)
|
|
let targetChecked = false;
|
|
if (action === 'toggle') {
|
|
const plugin = (window.installedPlugins || []).find(p => p.id === pluginId);
|
|
|
|
let currentEnabled;
|
|
if (plugin) {
|
|
currentEnabled = Boolean(plugin.enabled);
|
|
} else if (button.type === 'checkbox') {
|
|
currentEnabled = button.checked;
|
|
} else {
|
|
currentEnabled = false;
|
|
}
|
|
|
|
targetChecked = !currentEnabled; // Toggle to opposite state
|
|
}
|
|
|
|
// Wait for function to be available
|
|
let attempts = 0;
|
|
const maxAttempts = 20; // 1 second total
|
|
const checkInterval = setInterval(() => {
|
|
attempts++;
|
|
if (window[funcName] && typeof window[funcName] === 'function') {
|
|
clearInterval(checkInterval);
|
|
// Call the function directly
|
|
if (action === 'toggle') {
|
|
window.togglePlugin(pluginId, targetChecked);
|
|
} else {
|
|
window.configurePlugin(pluginId);
|
|
}
|
|
} else if (attempts >= maxAttempts) {
|
|
clearInterval(checkInterval);
|
|
console.error(`[GLOBAL DELEGATION] ${funcName} not available after ${maxAttempts} attempts`);
|
|
if (typeof showNotification === 'function') {
|
|
showNotification(`${funcName} not loaded. Please refresh the page.`, 'error');
|
|
}
|
|
}
|
|
}, 50);
|
|
return; // Don't proceed with normal handling
|
|
}
|
|
}
|
|
|
|
// Prevent default and stop propagation to avoid double handling
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
|
|
// If handlePluginAction exists, use it; otherwise handle directly
|
|
if (typeof handlePluginAction === 'function') {
|
|
handlePluginAction(event);
|
|
} else {
|
|
// Fallback: handle directly if functions are available
|
|
if (action === 'toggle' && window.togglePlugin) {
|
|
// Get the current enabled state from plugin data (source of truth)
|
|
const plugin = (window.installedPlugins || []).find(p => p.id === pluginId);
|
|
|
|
let currentEnabled;
|
|
if (plugin) {
|
|
currentEnabled = Boolean(plugin.enabled);
|
|
} else if (button.type === 'checkbox') {
|
|
currentEnabled = button.checked;
|
|
} else {
|
|
currentEnabled = false;
|
|
}
|
|
|
|
// Toggle the state - we want the opposite of current state
|
|
const isChecked = !currentEnabled;
|
|
|
|
// Prevent default behavior to avoid double-toggling and change event
|
|
// (Already done at start of function, but safe to repeat)
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
|
|
console.log('[DEBUG toggle fallback] Plugin:', pluginId, 'Current enabled (from data):', currentEnabled, 'New state:', isChecked);
|
|
|
|
window.togglePlugin(pluginId, isChecked);
|
|
} else if (action === 'configure' && window.configurePlugin) {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
window.configurePlugin(pluginId);
|
|
}
|
|
}
|
|
};
|
|
|
|
// Set up delegation on document (capture phase for better reliability)
|
|
document.addEventListener('click', handleGlobalPluginAction, true);
|
|
document.addEventListener('change', handleGlobalPluginAction, true);
|
|
console.log('[PLUGINS SCRIPT] Global event delegation set up');
|
|
})();
|
|
|
|
window.configurePlugin = window.configurePlugin || async function(pluginId) {
|
|
console.log('[DEBUG] ===== configurePlugin called =====');
|
|
console.log('[DEBUG] Plugin ID:', pluginId);
|
|
|
|
// Switch to the plugin's configuration tab instead of opening a modal
|
|
// This matches the behavior of clicking the plugin tab at the top
|
|
function getAppComponent() {
|
|
if (window.Alpine) {
|
|
const appElement = document.querySelector('[x-data="app()"]');
|
|
if (appElement && appElement._x_dataStack && appElement._x_dataStack[0]) {
|
|
return appElement._x_dataStack[0];
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
const appComponent = getAppComponent();
|
|
if (appComponent) {
|
|
// Set the active tab to the plugin ID
|
|
appComponent.activeTab = pluginId;
|
|
console.log('[DEBUG] Switched to plugin tab:', pluginId);
|
|
|
|
// Scroll to top of page to ensure the tab is visible
|
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
|
} else {
|
|
console.error('Alpine.js app instance not found');
|
|
if (typeof showNotification === 'function') {
|
|
showNotification('Unable to switch to plugin configuration. Please refresh the page.', 'error');
|
|
}
|
|
}
|
|
};
|
|
|
|
window.togglePlugin = window.togglePlugin || function(pluginId, enabled) {
|
|
console.log('[DEBUG] ===== togglePlugin called =====');
|
|
console.log('[DEBUG] Plugin ID:', pluginId, 'Enabled:', enabled);
|
|
const plugin = (window.installedPlugins || []).find(p => p.id === pluginId);
|
|
const pluginName = plugin ? (plugin.name || pluginId) : pluginId;
|
|
const action = enabled ? 'enabling' : 'disabling';
|
|
|
|
// Update UI immediately for better UX
|
|
const toggleCheckbox = document.getElementById(`toggle-${pluginId}`);
|
|
const toggleLabel = document.getElementById(`toggle-label-${pluginId}`);
|
|
const wrapperDiv = toggleCheckbox?.parentElement?.querySelector('.flex.items-center.gap-2');
|
|
const toggleTrack = wrapperDiv?.querySelector('.relative.w-14');
|
|
const toggleHandle = toggleTrack?.querySelector('.absolute');
|
|
|
|
if (toggleCheckbox) toggleCheckbox.checked = enabled;
|
|
|
|
// Update wrapper background and border
|
|
if (wrapperDiv) {
|
|
if (enabled) {
|
|
wrapperDiv.classList.remove('bg-gray-50', 'border-gray-300');
|
|
wrapperDiv.classList.add('bg-green-50', 'border-green-500');
|
|
} else {
|
|
wrapperDiv.classList.remove('bg-green-50', 'border-green-500');
|
|
wrapperDiv.classList.add('bg-gray-50', 'border-gray-300');
|
|
}
|
|
}
|
|
|
|
// Update toggle track
|
|
if (toggleTrack) {
|
|
if (enabled) {
|
|
toggleTrack.classList.remove('bg-gray-300');
|
|
toggleTrack.classList.add('bg-green-500');
|
|
} else {
|
|
toggleTrack.classList.remove('bg-green-500');
|
|
toggleTrack.classList.add('bg-gray-300');
|
|
}
|
|
}
|
|
|
|
// Update toggle handle
|
|
if (toggleHandle) {
|
|
if (enabled) {
|
|
toggleHandle.classList.add('translate-x-full', 'border-green-500');
|
|
toggleHandle.classList.remove('border-gray-400');
|
|
toggleHandle.innerHTML = '<i class="fas fa-check text-green-600 text-xs"></i>';
|
|
} else {
|
|
toggleHandle.classList.remove('translate-x-full', 'border-green-500');
|
|
toggleHandle.classList.add('border-gray-400');
|
|
toggleHandle.innerHTML = '<i class="fas fa-times text-gray-400 text-xs"></i>';
|
|
}
|
|
}
|
|
|
|
// Update label with icon and text
|
|
if (toggleLabel) {
|
|
if (enabled) {
|
|
toggleLabel.className = 'text-sm font-semibold text-green-700 flex items-center gap-1.5';
|
|
toggleLabel.innerHTML = '<i class="fas fa-toggle-on text-green-600"></i><span>Enabled</span>';
|
|
} else {
|
|
toggleLabel.className = 'text-sm font-semibold text-gray-600 flex items-center gap-1.5';
|
|
toggleLabel.innerHTML = '<i class="fas fa-toggle-off text-gray-400"></i><span>Disabled</span>';
|
|
}
|
|
}
|
|
|
|
if (typeof showNotification === 'function') {
|
|
showNotification(`${action.charAt(0).toUpperCase() + action.slice(1)} ${pluginName}...`, 'info');
|
|
}
|
|
|
|
fetch('/api/v3/plugins/toggle', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ plugin_id: pluginId, enabled: enabled })
|
|
})
|
|
.then(response => response.json())
|
|
.then(data => {
|
|
if (typeof showNotification === 'function') {
|
|
showNotification(data.message, data.status);
|
|
}
|
|
if (data.status === 'success') {
|
|
// Update local state
|
|
if (plugin) {
|
|
plugin.enabled = enabled;
|
|
}
|
|
// Refresh the list to ensure consistency
|
|
if (typeof loadInstalledPlugins === 'function') {
|
|
loadInstalledPlugins();
|
|
}
|
|
} else {
|
|
// Revert the toggle if API call failed
|
|
if (plugin) {
|
|
plugin.enabled = !enabled;
|
|
}
|
|
if (typeof loadInstalledPlugins === 'function') {
|
|
loadInstalledPlugins();
|
|
}
|
|
}
|
|
})
|
|
.catch(error => {
|
|
if (typeof showNotification === 'function') {
|
|
showNotification('Error toggling plugin: ' + error.message, 'error');
|
|
}
|
|
// Revert the toggle if API call failed
|
|
if (plugin) {
|
|
plugin.enabled = !enabled;
|
|
}
|
|
if (typeof loadInstalledPlugins === 'function') {
|
|
loadInstalledPlugins();
|
|
}
|
|
});
|
|
};
|
|
|
|
// Verify functions are defined (debug only)
|
|
if (_PLUGIN_DEBUG_EARLY) {
|
|
console.log('[PLUGINS SCRIPT] Functions defined:', {
|
|
configurePlugin: typeof window.configurePlugin,
|
|
togglePlugin: typeof window.togglePlugin
|
|
});
|
|
if (typeof window.configurePlugin === 'function') {
|
|
console.log('[PLUGINS SCRIPT] ✓ configurePlugin ready');
|
|
}
|
|
if (typeof window.togglePlugin === 'function') {
|
|
console.log('[PLUGINS SCRIPT] ✓ togglePlugin ready');
|
|
}
|
|
}
|
|
|
|
(function() {
|
|
'use strict';
|
|
|
|
if (_PLUGIN_DEBUG_EARLY) console.log('Plugin manager script starting...');
|
|
|
|
// Local variables for this instance
|
|
let installedPlugins = [];
|
|
window.currentPluginConfig = null;
|
|
let pluginStoreCache = null; // Cache for plugin store to speed up subsequent loads
|
|
let cacheTimestamp = null;
|
|
const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes in milliseconds
|
|
let onDemandStatusInterval = null;
|
|
let currentOnDemandPluginId = null;
|
|
let hasLoadedOnDemandStatus = false;
|
|
|
|
// Shared on-demand status store (mirrors Alpine store when available)
|
|
window.__onDemandStore = window.__onDemandStore || {
|
|
loading: true,
|
|
state: {},
|
|
service: {},
|
|
error: null,
|
|
lastUpdated: null
|
|
};
|
|
|
|
function ensureOnDemandStore() {
|
|
if (window.Alpine && typeof Alpine.store === 'function') {
|
|
if (!Alpine.store('onDemand')) {
|
|
Alpine.store('onDemand', {
|
|
loading: window.__onDemandStore.loading,
|
|
state: window.__onDemandStore.state,
|
|
service: window.__onDemandStore.service,
|
|
error: window.__onDemandStore.error,
|
|
lastUpdated: window.__onDemandStore.lastUpdated
|
|
});
|
|
}
|
|
const store = Alpine.store('onDemand');
|
|
window.__onDemandStore = store;
|
|
return store;
|
|
}
|
|
return window.__onDemandStore;
|
|
}
|
|
|
|
function markOnDemandLoading() {
|
|
const store = ensureOnDemandStore();
|
|
store.loading = true;
|
|
store.error = null;
|
|
}
|
|
|
|
function updateOnDemandSnapshot(store) {
|
|
if (!window.__onDemandStore) {
|
|
window.__onDemandStore = {};
|
|
}
|
|
window.__onDemandStore.loading = store.loading;
|
|
window.__onDemandStore.state = store.state;
|
|
window.__onDemandStore.service = store.service;
|
|
window.__onDemandStore.error = store.error;
|
|
window.__onDemandStore.lastUpdated = store.lastUpdated;
|
|
}
|
|
|
|
function updateOnDemandStore(data) {
|
|
const store = ensureOnDemandStore();
|
|
store.loading = false;
|
|
store.state = data?.state || {};
|
|
store.service = data?.service || {};
|
|
store.error = (data?.state?.status === 'error') ? (data.state.error || data.message || 'On-demand error') : null;
|
|
store.lastUpdated = Date.now();
|
|
updateOnDemandSnapshot(store);
|
|
document.dispatchEvent(new CustomEvent('onDemand:updated', {
|
|
detail: {
|
|
state: store.state,
|
|
service: store.service,
|
|
error: store.error,
|
|
lastUpdated: store.lastUpdated
|
|
}
|
|
}));
|
|
}
|
|
|
|
function setOnDemandError(message) {
|
|
const store = ensureOnDemandStore();
|
|
store.loading = false;
|
|
store.state = {};
|
|
store.service = {};
|
|
store.error = message || 'Failed to load on-demand status';
|
|
store.lastUpdated = Date.now();
|
|
updateOnDemandSnapshot(store);
|
|
document.dispatchEvent(new CustomEvent('onDemand:updated', {
|
|
detail: {
|
|
state: store.state,
|
|
service: store.service,
|
|
error: store.error,
|
|
lastUpdated: store.lastUpdated
|
|
}
|
|
}));
|
|
}
|
|
|
|
// Track initialization state
|
|
window.pluginManager = window.pluginManager || {};
|
|
window.pluginManager.initialized = false;
|
|
window.pluginManager.initializing = false; // Track if initialization is in progress
|
|
|
|
// Initialize when DOM is ready or when HTMX loads content
|
|
window.initPluginsPage = function() {
|
|
// Prevent duplicate initialization
|
|
if (window.pluginManager.initialized || window.pluginManager.initializing) {
|
|
console.log('Plugin page already initialized or initializing, skipping...');
|
|
return;
|
|
}
|
|
|
|
// Check if required elements exist
|
|
const installedGrid = document.getElementById('installed-plugins-grid');
|
|
if (!installedGrid) {
|
|
console.log('Plugin elements not ready yet');
|
|
return false;
|
|
}
|
|
|
|
window.pluginManager.initializing = true;
|
|
window.__pluginDomReady = true;
|
|
|
|
// If we fetched data before the DOM existed, render it now
|
|
if (window.__pendingInstalledPlugins) {
|
|
console.log('[RENDER] Applying pending installed plugins data');
|
|
renderInstalledPlugins(window.__pendingInstalledPlugins);
|
|
window.__pendingInstalledPlugins = null;
|
|
}
|
|
if (window.__pendingStorePlugins) {
|
|
console.log('[RENDER] Applying pending plugin store data');
|
|
renderPluginStore(window.__pendingStorePlugins);
|
|
window.__pendingStorePlugins = null;
|
|
}
|
|
|
|
initializePlugins();
|
|
|
|
// Event listeners (remove old ones first to prevent duplicates)
|
|
const refreshBtn = document.getElementById('refresh-plugins-btn');
|
|
const updateAllBtn = document.getElementById('update-all-plugins-btn');
|
|
const restartBtn = document.getElementById('restart-display-btn');
|
|
const searchBtn = document.getElementById('search-plugins-btn');
|
|
const closeBtn = document.getElementById('close-plugin-config');
|
|
const closeOnDemandModalBtn = document.getElementById('close-on-demand-modal');
|
|
const cancelOnDemandBtn = document.getElementById('cancel-on-demand');
|
|
const onDemandForm = document.getElementById('on-demand-form');
|
|
const onDemandModal = document.getElementById('on-demand-modal');
|
|
|
|
if (refreshBtn) {
|
|
refreshBtn.replaceWith(refreshBtn.cloneNode(true));
|
|
document.getElementById('refresh-plugins-btn').addEventListener('click', refreshPlugins);
|
|
}
|
|
if (updateAllBtn) {
|
|
updateAllBtn.replaceWith(updateAllBtn.cloneNode(true));
|
|
document.getElementById('update-all-plugins-btn').addEventListener('click', runUpdateAllPlugins);
|
|
}
|
|
if (restartBtn) {
|
|
restartBtn.replaceWith(restartBtn.cloneNode(true));
|
|
document.getElementById('restart-display-btn').addEventListener('click', restartDisplay);
|
|
}
|
|
if (searchBtn) {
|
|
searchBtn.replaceWith(searchBtn.cloneNode(true));
|
|
document.getElementById('search-plugins-btn').addEventListener('click', searchPluginStore);
|
|
}
|
|
if (closeBtn) {
|
|
closeBtn.replaceWith(closeBtn.cloneNode(true));
|
|
document.getElementById('close-plugin-config').addEventListener('click', closePluginConfigModal);
|
|
|
|
// View toggle buttons
|
|
document.getElementById('view-toggle-form')?.addEventListener('click', () => switchPluginConfigView('form'));
|
|
document.getElementById('view-toggle-json')?.addEventListener('click', () => switchPluginConfigView('json'));
|
|
|
|
// Reset to defaults button
|
|
document.getElementById('reset-to-defaults-btn')?.addEventListener('click', resetPluginConfigToDefaults);
|
|
|
|
// JSON editor save button
|
|
document.getElementById('save-json-config-btn')?.addEventListener('click', saveConfigFromJsonEditor);
|
|
}
|
|
if (closeOnDemandModalBtn) {
|
|
closeOnDemandModalBtn.replaceWith(closeOnDemandModalBtn.cloneNode(true));
|
|
document.getElementById('close-on-demand-modal').addEventListener('click', closeOnDemandModal);
|
|
}
|
|
if (cancelOnDemandBtn) {
|
|
cancelOnDemandBtn.replaceWith(cancelOnDemandBtn.cloneNode(true));
|
|
document.getElementById('cancel-on-demand').addEventListener('click', closeOnDemandModal);
|
|
}
|
|
if (onDemandForm) {
|
|
onDemandForm.replaceWith(onDemandForm.cloneNode(true));
|
|
document.getElementById('on-demand-form').addEventListener('submit', submitOnDemandRequest);
|
|
}
|
|
if (onDemandModal) {
|
|
onDemandModal.onclick = closeOnDemandModalOnBackdrop;
|
|
}
|
|
|
|
loadOnDemandStatus(true);
|
|
startOnDemandStatusPolling();
|
|
|
|
window.pluginManager.initialized = true;
|
|
window.pluginManager.initializing = false;
|
|
return true;
|
|
}
|
|
|
|
// Consolidated initialization function
|
|
function initializePluginPageWhenReady() {
|
|
console.log('Checking for plugin elements...');
|
|
return window.initPluginsPage();
|
|
}
|
|
|
|
// Single initialization entry point
|
|
(function() {
|
|
console.log('Plugin manager script loaded, setting up initialization...');
|
|
|
|
let initTimer = null;
|
|
|
|
function attemptInit() {
|
|
// Clear any pending timer
|
|
if (initTimer) {
|
|
clearTimeout(initTimer);
|
|
initTimer = null;
|
|
}
|
|
|
|
// Try immediate initialization
|
|
if (initializePluginPageWhenReady()) {
|
|
console.log('Initialized immediately');
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Strategy 1: Immediate check (for direct page loads)
|
|
if (document.readyState === 'complete' || document.readyState === 'interactive') {
|
|
// DOM is already ready, try immediately with a small delay to ensure scripts are loaded
|
|
initTimer = setTimeout(attemptInit, 50);
|
|
} else {
|
|
// Strategy 2: DOMContentLoaded (for direct page loads)
|
|
document.addEventListener('DOMContentLoaded', function() {
|
|
initTimer = setTimeout(attemptInit, 50);
|
|
});
|
|
}
|
|
|
|
// Strategy 3: HTMX afterSwap event (for HTMX-loaded content)
|
|
// This is the primary way plugins content is loaded
|
|
if (typeof htmx !== 'undefined') {
|
|
document.body.addEventListener('htmx:afterSwap', function(event) {
|
|
const target = event.detail.target;
|
|
// Check if plugins content was swapped in
|
|
if (target.id === 'plugins-content' ||
|
|
target.querySelector('#installed-plugins-grid') ||
|
|
document.getElementById('installed-plugins-grid')) {
|
|
console.log('HTMX swap detected for plugins, initializing...');
|
|
// Reset initialization flag to allow re-initialization after HTMX swap
|
|
window.pluginManager.initialized = false;
|
|
window.pluginManager.initializing = false;
|
|
initTimer = setTimeout(attemptInit, 100);
|
|
}
|
|
}, { once: false }); // Allow multiple swaps
|
|
}
|
|
})();
|
|
|
|
// Initialization guard to prevent multiple initializations
|
|
let pluginsInitialized = false;
|
|
|
|
function initializePlugins() {
|
|
// Guard against multiple initializations
|
|
if (pluginsInitialized) {
|
|
pluginLog('[INIT] Plugins already initialized, skipping');
|
|
return;
|
|
}
|
|
pluginsInitialized = true;
|
|
|
|
pluginLog('[INIT] Initializing plugins...');
|
|
|
|
// Check GitHub authentication status
|
|
checkGitHubAuthStatus();
|
|
|
|
// Load both installed plugins and plugin store
|
|
loadInstalledPlugins();
|
|
searchPluginStore(true); // Load plugin store with fresh metadata from GitHub
|
|
|
|
// Setup search functionality (with guard against duplicate listeners)
|
|
const searchInput = document.getElementById('plugin-search');
|
|
const categorySelect = document.getElementById('plugin-category');
|
|
|
|
if (searchInput && !searchInput._listenerSetup) {
|
|
searchInput._listenerSetup = true;
|
|
searchInput.addEventListener('input', debounce(searchPluginStore, 300));
|
|
}
|
|
if (categorySelect && !categorySelect._listenerSetup) {
|
|
categorySelect._listenerSetup = true;
|
|
categorySelect.addEventListener('change', searchPluginStore);
|
|
}
|
|
|
|
// Setup GitHub installation handlers
|
|
setupGitHubInstallHandlers();
|
|
|
|
// Setup collapsible section handlers
|
|
setupCollapsibleSections();
|
|
|
|
// Load saved repositories
|
|
loadSavedRepositories();
|
|
|
|
pluginLog('[INIT] Plugins initialized');
|
|
}
|
|
|
|
// Track in-flight requests to prevent duplicates
|
|
// ===== PLUGIN LOADING WITH REQUEST DEDUPLICATION & CACHING =====
|
|
// Prevents redundant API calls by caching results for a short time
|
|
const pluginLoadCache = {
|
|
promise: null, // Current in-flight request
|
|
data: null, // Cached plugin data
|
|
timestamp: 0, // When cache was last updated
|
|
TTL: 3000, // Cache valid for 3 seconds
|
|
isValid() {
|
|
return this.data && (Date.now() - this.timestamp < this.TTL);
|
|
},
|
|
invalidate() {
|
|
this.data = null;
|
|
this.timestamp = 0;
|
|
}
|
|
};
|
|
|
|
// Debug flag - set via localStorage.setItem('pluginDebug', 'true')
|
|
const PLUGIN_DEBUG = typeof localStorage !== 'undefined' && localStorage.getItem('pluginDebug') === 'true';
|
|
function pluginLog(...args) {
|
|
if (PLUGIN_DEBUG) console.log(...args);
|
|
}
|
|
|
|
function loadInstalledPlugins(forceRefresh = false) {
|
|
// Return cached data if valid and not forcing refresh
|
|
if (!forceRefresh && pluginLoadCache.isValid()) {
|
|
pluginLog('[CACHE] Returning cached plugin data');
|
|
// Update window.installedPlugins from cache
|
|
window.installedPlugins = pluginLoadCache.data;
|
|
// Dispatch event to notify Alpine component
|
|
document.dispatchEvent(new CustomEvent('pluginsUpdated', {
|
|
detail: { plugins: pluginLoadCache.data }
|
|
}));
|
|
pluginLog('[CACHE] Dispatched pluginsUpdated event from cache');
|
|
// Still render to ensure UI is updated
|
|
renderInstalledPlugins(pluginLoadCache.data);
|
|
return Promise.resolve(pluginLoadCache.data);
|
|
}
|
|
|
|
// If a request is already in progress, return the existing promise
|
|
if (pluginLoadCache.promise) {
|
|
pluginLog('[CACHE] Request in progress, returning existing promise');
|
|
return pluginLoadCache.promise;
|
|
}
|
|
|
|
pluginLog('[FETCH] Loading installed plugins...');
|
|
|
|
// Use PluginAPI if available, otherwise fall back to direct fetch
|
|
const fetchPromise = (window.PluginAPI && window.PluginAPI.getInstalledPlugins) ?
|
|
window.PluginAPI.getInstalledPlugins().then(plugins => {
|
|
const pluginsArray = Array.isArray(plugins) ? plugins : [];
|
|
return { status: 'success', data: { plugins: pluginsArray } };
|
|
}) :
|
|
fetch('/api/v3/plugins/installed').then(response => response.json());
|
|
|
|
// Store the promise
|
|
pluginLoadCache.promise = fetchPromise
|
|
.then(data => {
|
|
if (data.status === 'success') {
|
|
const pluginsData = data.data?.plugins;
|
|
installedPlugins = Array.isArray(pluginsData) ? pluginsData : [];
|
|
|
|
// Update cache
|
|
pluginLoadCache.data = installedPlugins;
|
|
pluginLoadCache.timestamp = Date.now();
|
|
|
|
// Always update window.installedPlugins to ensure Alpine component can detect changes
|
|
const currentPlugins = Array.isArray(window.installedPlugins) ? window.installedPlugins : [];
|
|
const currentIds = currentPlugins.map(p => p.id).sort().join(',');
|
|
const newIds = installedPlugins.map(p => p.id).sort().join(',');
|
|
const pluginsChanged = currentIds !== newIds;
|
|
|
|
if (pluginsChanged) {
|
|
window.installedPlugins = installedPlugins;
|
|
} else {
|
|
// Even if IDs haven't changed, update the array reference to trigger Alpine reactivity
|
|
window.installedPlugins = installedPlugins;
|
|
}
|
|
|
|
// Dispatch event to notify Alpine component to update tabs
|
|
document.dispatchEvent(new CustomEvent('pluginsUpdated', {
|
|
detail: { plugins: installedPlugins }
|
|
}));
|
|
pluginLog('[FETCH] Dispatched pluginsUpdated event with', installedPlugins.length, 'plugins');
|
|
|
|
pluginLog('[FETCH] Loaded', installedPlugins.length, 'plugins');
|
|
|
|
// Debug logging only when enabled
|
|
if (PLUGIN_DEBUG) {
|
|
installedPlugins.forEach(plugin => {
|
|
console.log(`[DEBUG] Plugin ${plugin.id}: enabled=${plugin.enabled}`);
|
|
});
|
|
}
|
|
|
|
renderInstalledPlugins(installedPlugins);
|
|
|
|
// Update count
|
|
const countEl = document.getElementById('installed-count');
|
|
if (countEl) {
|
|
countEl.textContent = installedPlugins.length + ' installed';
|
|
}
|
|
return installedPlugins;
|
|
} else {
|
|
const errorMsg = 'Failed to load installed plugins: ' + data.message;
|
|
showError(errorMsg);
|
|
throw new Error(errorMsg);
|
|
}
|
|
})
|
|
.catch(error => {
|
|
console.error('Error loading installed plugins:', error);
|
|
let errorMsg = 'Error loading plugins: ' + error.message;
|
|
if (error.message && error.message.includes('Failed to Fetch')) {
|
|
errorMsg += ' - Please try refreshing your browser.';
|
|
}
|
|
showError(errorMsg);
|
|
throw error;
|
|
})
|
|
.finally(() => {
|
|
// Clear the in-flight promise (but keep cache data)
|
|
pluginLoadCache.promise = null;
|
|
});
|
|
|
|
return pluginLoadCache.promise;
|
|
}
|
|
|
|
// Force refresh function for explicit user actions
|
|
function refreshInstalledPlugins() {
|
|
pluginLoadCache.invalidate();
|
|
return loadInstalledPlugins(true);
|
|
}
|
|
|
|
// Expose loadInstalledPlugins on window.pluginManager for Alpine.js integration
|
|
window.pluginManager.loadInstalledPlugins = loadInstalledPlugins;
|
|
// Note: searchPluginStore will be exposed after its definition (see below)
|
|
|
|
function renderInstalledPlugins(plugins) {
|
|
const container = document.getElementById('installed-plugins-grid');
|
|
if (!container) {
|
|
console.warn('[RENDER] installed-plugins-grid not yet available, deferring render until plugin tab loads');
|
|
window.__pendingInstalledPlugins = plugins;
|
|
return;
|
|
}
|
|
|
|
// Always update window.installedPlugins to ensure Alpine component reactivity
|
|
window.installedPlugins = plugins;
|
|
pluginLog('[RENDER] Set window.installedPlugins to:', plugins.length, 'plugins');
|
|
|
|
// Dispatch event to notify Alpine component to update tabs
|
|
document.dispatchEvent(new CustomEvent('pluginsUpdated', {
|
|
detail: { plugins: plugins }
|
|
}));
|
|
pluginLog('[RENDER] Dispatched pluginsUpdated event');
|
|
|
|
// Also try direct Alpine update as fallback
|
|
if (window.Alpine && document.querySelector('[x-data="app()"]')) {
|
|
const appElement = document.querySelector('[x-data="app()"]');
|
|
if (appElement && appElement._x_dataStack && appElement._x_dataStack[0]) {
|
|
appElement._x_dataStack[0].installedPlugins = plugins;
|
|
if (typeof appElement._x_dataStack[0].updatePluginTabs === 'function') {
|
|
appElement._x_dataStack[0].updatePluginTabs();
|
|
pluginLog('[RENDER] Triggered Alpine.js to update plugin tabs directly');
|
|
}
|
|
}
|
|
}
|
|
|
|
if (plugins.length === 0) {
|
|
container.innerHTML = `
|
|
<div class="col-span-full empty-state">
|
|
<div class="empty-state-icon">
|
|
<i class="fas fa-plug"></i>
|
|
</div>
|
|
<p class="text-lg font-medium text-gray-700 mb-1">No plugins installed</p>
|
|
<p class="text-sm text-gray-500">Install plugins from the store to get started</p>
|
|
</div>
|
|
`;
|
|
return;
|
|
}
|
|
|
|
// Helper function to escape attributes for use in HTML
|
|
const escapeAttr = (text) => {
|
|
return (text || '').replace(/'/g, "\\'").replace(/"/g, '"');
|
|
};
|
|
|
|
// Helper function to escape for JavaScript strings (use JSON.stringify for proper escaping)
|
|
// JSON.stringify returns a quoted string, so we can use it directly in JavaScript
|
|
const escapeJs = (text) => {
|
|
return JSON.stringify(text || '');
|
|
};
|
|
|
|
container.innerHTML = plugins.map(plugin => {
|
|
// Convert enabled to boolean for consistent rendering
|
|
const enabledBool = Boolean(plugin.enabled);
|
|
|
|
// Debug: Log enabled status during rendering (only when debug enabled)
|
|
if (PLUGIN_DEBUG) {
|
|
console.log(`[DEBUG RENDER] Plugin ${plugin.id}: enabled=${enabledBool}`);
|
|
}
|
|
|
|
// Escape plugin ID for use in HTML attributes and JavaScript
|
|
const escapedPluginId = escapeAttr(plugin.id);
|
|
|
|
return `
|
|
<div class="plugin-card">
|
|
<div class="flex items-start justify-between mb-4">
|
|
<div class="flex-1 min-w-0">
|
|
<div class="flex items-center flex-wrap gap-2 mb-2">
|
|
<h4 class="font-semibold text-gray-900 text-base">${escapeHtml(plugin.name || plugin.id)}</h4>
|
|
${plugin.verified ? '<span class="badge badge-success"><i class="fas fa-check-circle mr-1"></i>Verified</span>' : ''}
|
|
</div>
|
|
<div class="text-sm text-gray-600 space-y-1.5 mb-3">
|
|
<p class="flex items-center"><i class="fas fa-user mr-2 text-gray-400 w-4"></i>${escapeHtml(plugin.author || 'Unknown')}</p>
|
|
<p class="flex items-center"><i class="fas fa-code-branch mr-2 text-gray-400 w-4"></i>${formatCommit(plugin.last_commit, plugin.branch)}</p>
|
|
<p class="flex items-center"><i class="fas fa-calendar mr-2 text-gray-400 w-4"></i>${formatDate(plugin.last_updated)}</p>
|
|
<p class="flex items-center"><i class="fas fa-folder mr-2 text-gray-400 w-4"></i>${escapeHtml(plugin.category || 'General')}</p>
|
|
${plugin.stars ? `<p class="flex items-center"><i class="fas fa-star mr-2 text-gray-400 w-4"></i>${plugin.stars} stars</p>` : ''}
|
|
</div>
|
|
<p class="text-sm text-gray-700 leading-relaxed">${escapeHtml(plugin.description || 'No description available')}</p>
|
|
</div>
|
|
<!-- Toggle Switch in Top Right -->
|
|
<div class="flex-shrink-0 ml-4">
|
|
<label class="relative inline-flex items-center cursor-pointer group">
|
|
<input type="checkbox"
|
|
class="sr-only peer"
|
|
id="toggle-${escapedPluginId}"
|
|
${enabledBool ? 'checked' : ''}
|
|
data-plugin-id="${escapedPluginId}"
|
|
data-action="toggle">
|
|
<div class="flex items-center gap-2 px-3 py-1.5 rounded-lg border-2 transition-all duration-200 ${enabledBool ? 'bg-green-50 border-green-500' : 'bg-gray-50 border-gray-300'} hover:shadow-md group-hover:scale-105">
|
|
<!-- Toggle Switch -->
|
|
<div class="relative w-14 h-7 ${enabledBool ? 'bg-green-500' : 'bg-gray-300'} peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 rounded-full peer peer-checked:bg-green-500 transition-colors duration-200 ease-in-out shadow-inner">
|
|
<div class="absolute top-[3px] left-[3px] bg-white ${enabledBool ? 'translate-x-full' : ''} border-2 ${enabledBool ? 'border-green-500' : 'border-gray-400'} rounded-full h-5 w-5 transition-all duration-200 ease-in-out shadow-sm flex items-center justify-center">
|
|
${enabledBool ? '<i class="fas fa-check text-green-600 text-xs"></i>' : '<i class="fas fa-times text-gray-400 text-xs"></i>'}
|
|
</div>
|
|
</div>
|
|
<!-- Label with Icon -->
|
|
<span class="text-sm font-semibold ${enabledBool ? 'text-green-700' : 'text-gray-600'} flex items-center gap-1.5" id="toggle-label-${escapedPluginId}">
|
|
${enabledBool ? '<i class="fas fa-toggle-on text-green-600"></i>' : '<i class="fas fa-toggle-off text-gray-400"></i>'}
|
|
<span>${enabledBool ? 'Enabled' : 'Disabled'}</span>
|
|
</span>
|
|
</div>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Plugin Tags -->
|
|
${plugin.tags && plugin.tags.length > 0 ? `
|
|
<div class="flex flex-wrap gap-1.5 mb-4">
|
|
${plugin.tags.map(tag => `<span class="badge badge-info">${escapeHtml(tag)}</span>`).join('')}
|
|
</div>
|
|
` : ''}
|
|
|
|
<!-- Plugin Actions -->
|
|
<div class="flex flex-wrap gap-2 mt-4 pt-4 border-t border-gray-200">
|
|
<button class="btn bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-md text-sm flex-1 font-semibold"
|
|
data-plugin-id="${escapedPluginId}"
|
|
data-action="configure">
|
|
<i class="fas fa-cog mr-2"></i>Configure
|
|
</button>
|
|
<button class="btn bg-gray-600 hover:bg-gray-700 text-white px-4 py-2 rounded-md text-sm flex-1 font-semibold"
|
|
data-plugin-id="${escapedPluginId}"
|
|
data-action="update">
|
|
<i class="fas fa-sync mr-2"></i>Update
|
|
</button>
|
|
<button class="btn bg-red-600 hover:bg-red-700 text-white px-4 py-2 rounded-md text-sm flex-1 font-semibold"
|
|
data-plugin-id="${escapedPluginId}"
|
|
data-action="uninstall">
|
|
<i class="fas fa-trash mr-2"></i>Uninstall
|
|
</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}).join('');
|
|
|
|
// Set up event delegation for plugin action buttons (fallback if onclick doesn't work)
|
|
// Only set up once per container to avoid redundant listeners
|
|
const setupEventDelegation = () => {
|
|
const container = document.getElementById('installed-plugins-grid');
|
|
if (!container) {
|
|
pluginLog('[RENDER] installed-plugins-grid not found for event delegation');
|
|
return;
|
|
}
|
|
|
|
// Skip if already set up (guard against multiple calls)
|
|
if (container._eventDelegationSetup) {
|
|
pluginLog('[RENDER] Event delegation already set up, skipping');
|
|
return;
|
|
}
|
|
|
|
// Mark as set up
|
|
container._eventDelegationSetup = true;
|
|
container._pluginActionHandler = handlePluginAction;
|
|
|
|
// Add listeners for both click and change events
|
|
container.addEventListener('click', handlePluginAction, true);
|
|
container.addEventListener('change', handlePluginAction, true);
|
|
pluginLog('[RENDER] Event delegation set up for installed-plugins-grid');
|
|
};
|
|
|
|
// Set up immediately
|
|
setupEventDelegation();
|
|
|
|
// Also retry after a short delay to ensure it's attached even if container wasn't ready
|
|
setTimeout(setupEventDelegation, 100);
|
|
}
|
|
|
|
function handlePluginAction(event) {
|
|
// Check for both button and input (for toggle)
|
|
const button = event.target.closest('button[data-action]') || event.target.closest('input[data-action]');
|
|
if (!button) return;
|
|
|
|
const action = button.getAttribute('data-action');
|
|
const pluginId = button.getAttribute('data-plugin-id');
|
|
|
|
if (!pluginId) return;
|
|
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
|
|
console.log('[EVENT DELEGATION] Plugin action:', action, 'Plugin ID:', pluginId);
|
|
|
|
// Helper function to wait for a function to be available
|
|
const waitForFunction = (funcName, maxAttempts = 10, delay = 50) => {
|
|
return new Promise((resolve, reject) => {
|
|
let attempts = 0;
|
|
const check = () => {
|
|
attempts++;
|
|
if (window[funcName] && typeof window[funcName] === 'function') {
|
|
resolve(window[funcName]);
|
|
} else if (attempts >= maxAttempts) {
|
|
reject(new Error(`${funcName} not available after ${maxAttempts} attempts`));
|
|
} else {
|
|
setTimeout(check, delay);
|
|
}
|
|
};
|
|
check();
|
|
});
|
|
};
|
|
|
|
switch(action) {
|
|
case 'toggle':
|
|
// Get the current enabled state from plugin data (source of truth)
|
|
// rather than from the checkbox DOM which might be out of sync
|
|
const plugin = (window.installedPlugins || []).find(p => p.id === pluginId);
|
|
|
|
// Special handling: If plugin data isn't found or is stale, fallback to DOM but be careful
|
|
// If the user clicked the checkbox, the 'checked' property has *already* toggled in the DOM
|
|
// (even though we preventDefault later, sometimes it's too late for the property read)
|
|
// However, we used preventDefault() in the global handler, so the checkbox state *should* be reliable if we didn't touch it.
|
|
|
|
// BUT: The issue is that 'currentEnabled' calculation might be wrong if window.installedPlugins is outdated.
|
|
// If the user toggles ON, enabled becomes true. If they click again, we want enabled=false.
|
|
|
|
// Let's try a simpler approach: Use the checkbox state as the source of truth for the *desired* state
|
|
// Since we preventDefault(), the checkbox state reflects the *old* state (before the click)
|
|
// wait... if we preventDefault() on 'click', the checkbox does NOT change visually or internally.
|
|
// So button.checked is the OLD state.
|
|
// We want the NEW state to be !button.checked.
|
|
|
|
let currentEnabled;
|
|
|
|
if (plugin) {
|
|
currentEnabled = Boolean(plugin.enabled);
|
|
} else if (button.type === 'checkbox') {
|
|
currentEnabled = button.checked;
|
|
} else {
|
|
currentEnabled = false;
|
|
}
|
|
|
|
// Toggle the state - we want the opposite of current state
|
|
const isChecked = !currentEnabled;
|
|
|
|
console.log('[DEBUG toggle] Plugin:', pluginId, 'Current enabled (from data):', currentEnabled, 'New state:', isChecked, 'Event type:', event.type);
|
|
|
|
waitForFunction('togglePlugin', 10, 50)
|
|
.then(toggleFunc => {
|
|
toggleFunc(pluginId, isChecked);
|
|
})
|
|
.catch(error => {
|
|
console.error('[EVENT DELEGATION]', error.message);
|
|
if (typeof showNotification === 'function') {
|
|
showNotification('Toggle function not loaded. Please refresh the page.', 'error');
|
|
} else {
|
|
alert('Toggle function not loaded. Please refresh the page.');
|
|
}
|
|
});
|
|
break;
|
|
case 'configure':
|
|
waitForFunction('configurePlugin', 10, 50)
|
|
.then(configureFunc => {
|
|
configureFunc(pluginId);
|
|
})
|
|
.catch(error => {
|
|
console.error('[EVENT DELEGATION]', error.message);
|
|
if (typeof showNotification === 'function') {
|
|
showNotification('Configure function not loaded. Please refresh the page.', 'error');
|
|
} else {
|
|
alert('Configure function not loaded. Please refresh the page.');
|
|
}
|
|
});
|
|
break;
|
|
case 'update':
|
|
waitForFunction('updatePlugin', 10, 50)
|
|
.then(updateFunc => {
|
|
updateFunc(pluginId);
|
|
})
|
|
.catch(error => {
|
|
console.error('[EVENT DELEGATION]', error.message);
|
|
if (typeof showNotification === 'function') {
|
|
showNotification('Update function not loaded. Please refresh the page.', 'error');
|
|
} else {
|
|
alert('Update function not loaded. Please refresh the page.');
|
|
}
|
|
});
|
|
break;
|
|
case 'uninstall':
|
|
waitForFunction('uninstallPlugin', 10, 50)
|
|
.then(uninstallFunc => {
|
|
uninstallFunc(pluginId);
|
|
})
|
|
.catch(error => {
|
|
console.error('[EVENT DELEGATION]', error.message);
|
|
if (typeof showNotification === 'function') {
|
|
showNotification('Uninstall function not loaded. Please refresh the page.', 'error');
|
|
} else {
|
|
alert('Uninstall function not loaded. Please refresh the page.');
|
|
}
|
|
});
|
|
break;
|
|
}
|
|
}
|
|
|
|
function findInstalledPlugin(pluginId) {
|
|
const plugins = window.installedPlugins || installedPlugins || [];
|
|
if (!plugins || plugins.length === 0) {
|
|
return undefined;
|
|
}
|
|
return plugins.find(plugin => plugin.id === pluginId);
|
|
}
|
|
|
|
function resolvePluginDisplayName(pluginId) {
|
|
const plugin = findInstalledPlugin(pluginId);
|
|
if (!plugin) {
|
|
return pluginId;
|
|
}
|
|
return plugin.name || pluginId;
|
|
}
|
|
|
|
function loadOnDemandStatus(fromRefreshButton = false) {
|
|
if (!hasLoadedOnDemandStatus || fromRefreshButton) {
|
|
markOnDemandLoading();
|
|
}
|
|
|
|
return fetch('/api/v3/display/on-demand/status')
|
|
.then(response => response.json())
|
|
.then(result => {
|
|
if (result.status === 'success') {
|
|
updateOnDemandStore(result.data);
|
|
hasLoadedOnDemandStatus = true;
|
|
if (fromRefreshButton && typeof showNotification === 'function') {
|
|
showNotification('On-demand status refreshed', 'success');
|
|
}
|
|
} else {
|
|
const message = result.message || 'Failed to load on-demand status';
|
|
setOnDemandError(message);
|
|
if (typeof showNotification === 'function') {
|
|
showNotification(message, 'error');
|
|
}
|
|
}
|
|
})
|
|
.catch(error => {
|
|
console.error('Error fetching on-demand status:', error);
|
|
setOnDemandError(error?.message || 'Error fetching on-demand status');
|
|
if (typeof showNotification === 'function') {
|
|
showNotification('Error fetching on-demand status: ' + error.message, 'error');
|
|
}
|
|
});
|
|
}
|
|
|
|
function startOnDemandStatusPolling() {
|
|
if (onDemandStatusInterval) {
|
|
clearInterval(onDemandStatusInterval);
|
|
}
|
|
onDemandStatusInterval = setInterval(() => loadOnDemandStatus(false), 15000);
|
|
}
|
|
|
|
window.loadOnDemandStatus = loadOnDemandStatus;
|
|
|
|
function runUpdateAllPlugins() {
|
|
const button = document.getElementById('update-all-plugins-btn');
|
|
|
|
if (!button) {
|
|
if (typeof showNotification === 'function') {
|
|
showNotification('Unable to locate bulk update controls. Refresh the Plugin Manager tab.', 'error');
|
|
} else {
|
|
console.error('update-all-plugins-btn element not found');
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (button.dataset.running === 'true') {
|
|
return;
|
|
}
|
|
|
|
if (typeof window.updateAllPlugins !== 'function') {
|
|
if (typeof showNotification === 'function') {
|
|
showNotification('Bulk update handler unavailable. Refresh the Plugin Manager tab.', 'error');
|
|
} else {
|
|
console.error('window.updateAllPlugins is not defined');
|
|
}
|
|
return;
|
|
}
|
|
|
|
const originalContent = button.innerHTML;
|
|
button.dataset.running = 'true';
|
|
button.disabled = true;
|
|
button.classList.add('opacity-60', 'cursor-wait');
|
|
button.innerHTML = '<i class="fas fa-sync fa-spin mr-2"></i>Updating...';
|
|
|
|
Promise.resolve(window.updateAllPlugins())
|
|
.catch(error => {
|
|
console.error('Error updating all plugins:', error);
|
|
if (typeof showNotification === 'function') {
|
|
showNotification('Error updating all plugins: ' + error.message, 'error');
|
|
}
|
|
})
|
|
.finally(() => {
|
|
button.innerHTML = originalContent;
|
|
button.disabled = false;
|
|
button.classList.remove('opacity-60', 'cursor-wait');
|
|
button.dataset.running = 'false';
|
|
});
|
|
}
|
|
|
|
// Store the real implementation and replace the stub
|
|
window.__openOnDemandModalImpl = function(pluginId) {
|
|
const plugin = findInstalledPlugin(pluginId);
|
|
if (!plugin) {
|
|
if (typeof showNotification === 'function') {
|
|
showNotification(`Plugin ${pluginId} not found`, 'error');
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (!plugin.enabled) {
|
|
if (typeof showNotification === 'function') {
|
|
showNotification('Enable the plugin before running it on-demand.', 'error');
|
|
}
|
|
return;
|
|
}
|
|
|
|
currentOnDemandPluginId = pluginId;
|
|
|
|
const modal = document.getElementById('on-demand-modal');
|
|
const modeSelect = document.getElementById('on-demand-mode');
|
|
const modeHint = document.getElementById('on-demand-mode-hint');
|
|
const durationInput = document.getElementById('on-demand-duration');
|
|
const pinnedCheckbox = document.getElementById('on-demand-pinned');
|
|
const startServiceCheckbox = document.getElementById('on-demand-start-service');
|
|
const modalTitle = document.getElementById('on-demand-modal-title');
|
|
|
|
if (!modal || !modeSelect || !modeHint || !durationInput || !pinnedCheckbox || !startServiceCheckbox || !modalTitle) {
|
|
console.error('On-demand modal elements not found');
|
|
return;
|
|
}
|
|
|
|
modalTitle.textContent = `Run ${resolvePluginDisplayName(pluginId)} On-Demand`;
|
|
modeSelect.innerHTML = '';
|
|
|
|
const displayModes = Array.isArray(plugin.display_modes) && plugin.display_modes.length > 0
|
|
? plugin.display_modes
|
|
: [pluginId];
|
|
|
|
displayModes.forEach(mode => {
|
|
const option = document.createElement('option');
|
|
option.value = mode;
|
|
option.textContent = mode;
|
|
modeSelect.appendChild(option);
|
|
});
|
|
|
|
if (displayModes.length > 1) {
|
|
modeHint.textContent = 'Select the display mode to show on the matrix.';
|
|
} else {
|
|
modeHint.textContent = 'This plugin exposes a single display mode.';
|
|
}
|
|
|
|
durationInput.value = '';
|
|
pinnedCheckbox.checked = false;
|
|
startServiceCheckbox.checked = true;
|
|
|
|
// Check service status and show warning if needed
|
|
fetch('/api/v3/display/on-demand/status')
|
|
.then(response => response.json())
|
|
.then(data => {
|
|
const serviceWarning = document.getElementById('on-demand-service-warning');
|
|
const serviceActive = data?.data?.service?.active || false;
|
|
|
|
if (serviceWarning) {
|
|
if (!serviceActive) {
|
|
serviceWarning.classList.remove('hidden');
|
|
// Auto-check the start service checkbox
|
|
startServiceCheckbox.checked = true;
|
|
} else {
|
|
serviceWarning.classList.add('hidden');
|
|
}
|
|
}
|
|
})
|
|
.catch(error => {
|
|
console.error('Error checking service status:', error);
|
|
});
|
|
|
|
modal.style.display = 'flex';
|
|
};
|
|
|
|
// Replace the stub with the real implementation
|
|
window.openOnDemandModal = window.__openOnDemandModalImpl;
|
|
|
|
function closeOnDemandModal() {
|
|
const modal = document.getElementById('on-demand-modal');
|
|
if (modal) {
|
|
modal.style.display = 'none';
|
|
}
|
|
currentOnDemandPluginId = null;
|
|
}
|
|
|
|
function submitOnDemandRequest(event) {
|
|
event.preventDefault();
|
|
if (!currentOnDemandPluginId) {
|
|
if (typeof showNotification === 'function') {
|
|
showNotification('Select a plugin before starting on-demand mode.', 'error');
|
|
}
|
|
return;
|
|
}
|
|
|
|
const form = document.getElementById('on-demand-form');
|
|
if (!form) {
|
|
return;
|
|
}
|
|
|
|
const formData = new FormData(form);
|
|
const mode = formData.get('mode');
|
|
const pinned = formData.get('pinned') === 'on';
|
|
const startService = formData.get('start_service') === 'on';
|
|
const durationValue = formData.get('duration');
|
|
|
|
const payload = {
|
|
plugin_id: currentOnDemandPluginId,
|
|
mode,
|
|
pinned,
|
|
start_service: startService
|
|
};
|
|
|
|
if (durationValue !== null && durationValue !== '') {
|
|
const parsedDuration = parseInt(durationValue, 10);
|
|
if (!Number.isNaN(parsedDuration) && parsedDuration >= 0) {
|
|
payload.duration = parsedDuration;
|
|
}
|
|
}
|
|
|
|
markOnDemandLoading();
|
|
|
|
fetch('/api/v3/display/on-demand/start', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify(payload)
|
|
})
|
|
.then(response => response.json())
|
|
.then(result => {
|
|
if (result.status === 'success') {
|
|
if (typeof showNotification === 'function') {
|
|
const pluginName = resolvePluginDisplayName(currentOnDemandPluginId);
|
|
showNotification(`Requested on-demand mode for ${pluginName}`, 'success');
|
|
}
|
|
closeOnDemandModal();
|
|
setTimeout(() => loadOnDemandStatus(true), 700);
|
|
} else {
|
|
if (typeof showNotification === 'function') {
|
|
showNotification(result.message || 'Failed to start on-demand mode', 'error');
|
|
}
|
|
}
|
|
})
|
|
.catch(error => {
|
|
console.error('Error starting on-demand mode:', error);
|
|
if (typeof showNotification === 'function') {
|
|
showNotification('Error starting on-demand mode: ' + error.message, 'error');
|
|
}
|
|
});
|
|
}
|
|
|
|
function requestOnDemandStop({ stopService = false } = {}) {
|
|
markOnDemandLoading();
|
|
return fetch('/api/v3/display/on-demand/stop', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify({
|
|
stop_service: stopService
|
|
})
|
|
})
|
|
.then(response => response.json())
|
|
.then(result => {
|
|
if (result.status === 'success') {
|
|
if (typeof showNotification === 'function') {
|
|
const message = stopService
|
|
? 'On-demand mode stop requested and display service will be stopped.'
|
|
: 'On-demand mode stop requested';
|
|
showNotification(message, 'success');
|
|
}
|
|
setTimeout(() => loadOnDemandStatus(true), 700);
|
|
} else {
|
|
if (typeof showNotification === 'function') {
|
|
showNotification(result.message || 'Failed to stop on-demand mode', 'error');
|
|
}
|
|
}
|
|
})
|
|
.catch(error => {
|
|
console.error('Error stopping on-demand mode:', error);
|
|
if (typeof showNotification === 'function') {
|
|
showNotification('Error stopping on-demand mode: ' + error.message, 'error');
|
|
}
|
|
});
|
|
}
|
|
|
|
function stopOnDemand(event) {
|
|
const stopService = event && event.shiftKey;
|
|
requestOnDemandStop({ stopService });
|
|
}
|
|
|
|
// Store the real implementation and replace the stub
|
|
window.__requestOnDemandStopImpl = requestOnDemandStop;
|
|
window.requestOnDemandStop = requestOnDemandStop;
|
|
|
|
function closeOnDemandModalOnBackdrop(event) {
|
|
if (event.target === event.currentTarget) {
|
|
closeOnDemandModal();
|
|
}
|
|
}
|
|
|
|
// configurePlugin is already defined at the top of the script - no need to redefine
|
|
|
|
window.showPluginConfigModal = function(pluginId, config) {
|
|
const modal = document.getElementById('plugin-config-modal');
|
|
const title = document.getElementById('plugin-config-title');
|
|
const content = document.getElementById('plugin-config-content');
|
|
|
|
if (!modal) {
|
|
console.error('[DEBUG] Plugin config modal element not found');
|
|
if (typeof showError === 'function') {
|
|
showError('Plugin configuration modal not found. Please refresh the page.');
|
|
} else if (typeof showNotification === 'function') {
|
|
showNotification('Plugin configuration modal not found. Please refresh the page.', 'error');
|
|
}
|
|
return;
|
|
}
|
|
|
|
console.log('[DEBUG] ===== Opening plugin config modal =====');
|
|
console.log('[DEBUG] Plugin ID:', pluginId);
|
|
console.log('[DEBUG] Config:', config);
|
|
|
|
// Check if modal elements exist (already checked above, but double-check for safety)
|
|
if (!title) {
|
|
console.error('[DEBUG] Plugin config title element not found');
|
|
if (typeof showError === 'function') {
|
|
showError('Plugin configuration title element not found.');
|
|
} else if (typeof showNotification === 'function') {
|
|
showNotification('Plugin configuration title element not found.', 'error');
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (!content) {
|
|
console.error('[DEBUG] Plugin config content element not found');
|
|
if (typeof showError === 'function') {
|
|
showError('Plugin configuration content element not found.');
|
|
} else if (typeof showNotification === 'function') {
|
|
showNotification('Plugin configuration content element not found.', 'error');
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Initialize state
|
|
currentPluginConfigState.pluginId = pluginId;
|
|
currentPluginConfigState.config = config || {};
|
|
currentPluginConfigState.jsonEditor = null;
|
|
|
|
// Reset view to form
|
|
switchPluginConfigView('form');
|
|
|
|
// Hide validation errors
|
|
displayValidationErrors([]);
|
|
|
|
title.textContent = `Configure ${pluginId}`;
|
|
|
|
// Show loading state while form is generated
|
|
content.innerHTML = '<div class="flex items-center justify-center py-8"><i class="fas fa-spinner fa-spin text-2xl text-blue-600"></i></div>';
|
|
|
|
// Move modal to body to avoid z-index/overflow issues
|
|
if (modal.parentElement !== document.body) {
|
|
document.body.appendChild(modal);
|
|
}
|
|
|
|
// Remove any inline display:none that might be in the HTML FIRST
|
|
// This is critical because the HTML template has style="display: none;" inline
|
|
// We need to remove it before setting new styles
|
|
let currentStyle = modal.getAttribute('style') || '';
|
|
if (currentStyle.includes('display: none') || currentStyle.includes('display:none')) {
|
|
currentStyle = currentStyle.replace(/display:\s*none[;]?/gi, '').trim();
|
|
// Clean up any double semicolons or trailing semicolons
|
|
currentStyle = currentStyle.replace(/;;+/g, ';').replace(/^;|;$/g, '');
|
|
if (currentStyle) {
|
|
modal.setAttribute('style', currentStyle);
|
|
} else {
|
|
modal.removeAttribute('style');
|
|
}
|
|
}
|
|
|
|
// Show modal immediately - use important to override any other styles
|
|
// Also ensure visibility, opacity, and z-index are set correctly
|
|
modal.style.setProperty('display', 'flex', 'important');
|
|
modal.style.setProperty('visibility', 'visible', 'important');
|
|
modal.style.setProperty('opacity', '1', 'important');
|
|
modal.style.setProperty('z-index', '9999', 'important');
|
|
modal.style.setProperty('position', 'fixed', 'important');
|
|
|
|
// Ensure modal content is also visible
|
|
const modalContent = modal.querySelector('.modal-content');
|
|
if (modalContent) {
|
|
modalContent.style.setProperty('display', 'block', 'important');
|
|
modalContent.style.setProperty('visibility', 'visible', 'important');
|
|
modalContent.style.setProperty('opacity', '1', 'important');
|
|
}
|
|
|
|
console.log('[DEBUG] Modal display set to flex');
|
|
console.log('[DEBUG] Modal computed style:', window.getComputedStyle(modal).display);
|
|
console.log('[DEBUG] Modal z-index:', window.getComputedStyle(modal).zIndex);
|
|
console.log('[DEBUG] Modal visibility:', window.getComputedStyle(modal).visibility);
|
|
console.log('[DEBUG] Modal opacity:', window.getComputedStyle(modal).opacity);
|
|
console.log('[DEBUG] Modal in DOM:', document.body.contains(modal));
|
|
console.log('[DEBUG] Modal parent:', modal.parentElement?.tagName);
|
|
console.log('[DEBUG] Modal rect:', modal.getBoundingClientRect());
|
|
|
|
// Load schema for validation
|
|
fetch(`/api/v3/plugins/schema?plugin_id=${pluginId}`)
|
|
.then(r => r.json())
|
|
.then(schemaData => {
|
|
if (schemaData.status === 'success' && schemaData.data?.schema) {
|
|
currentPluginConfigState.schema = schemaData.data.schema;
|
|
}
|
|
})
|
|
.catch(err => console.warn('Could not load schema:', err));
|
|
|
|
// Generate form asynchronously
|
|
generatePluginConfigForm(pluginId, config)
|
|
.then(formHtml => {
|
|
console.log('[DEBUG] Form generated, setting content. HTML length:', formHtml.length);
|
|
content.innerHTML = formHtml;
|
|
|
|
// Attach form submit handler after form is inserted
|
|
const form = document.getElementById('plugin-config-form');
|
|
if (form) {
|
|
form.addEventListener('submit', handlePluginConfigSubmit);
|
|
console.log('Form submit handler attached');
|
|
}
|
|
|
|
})
|
|
.catch(error => {
|
|
console.error('Error generating config form:', error);
|
|
content.innerHTML = '<p class="text-red-600">Error loading configuration form</p>';
|
|
});
|
|
}
|
|
|
|
// Helper function to get the full property object from schema
|
|
function getSchemaProperty(schema, path) {
|
|
if (!schema || !schema.properties) return null;
|
|
|
|
const parts = path.split('.');
|
|
let current = schema.properties;
|
|
|
|
for (let i = 0; i < parts.length; i++) {
|
|
const part = parts[i];
|
|
if (current && current[part]) {
|
|
if (i === parts.length - 1) {
|
|
// Last part - return the property
|
|
return current[part];
|
|
} else if (current[part].properties) {
|
|
// Navigate into nested object
|
|
current = current[part].properties;
|
|
} else {
|
|
return null;
|
|
}
|
|
} else {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
// Helper function to find property type in nested schema using dot notation
|
|
function getSchemaPropertyType(schema, path) {
|
|
const prop = getSchemaProperty(schema, path);
|
|
return prop; // Return the full property object (was returning just type, but callers expect object)
|
|
}
|
|
|
|
// Helper function to convert dot notation to nested object
|
|
function dotToNested(obj) {
|
|
const result = {};
|
|
|
|
for (const key in obj) {
|
|
const parts = key.split('.');
|
|
let current = result;
|
|
|
|
for (let i = 0; i < parts.length - 1; i++) {
|
|
if (!current[parts[i]]) {
|
|
current[parts[i]] = {};
|
|
}
|
|
current = current[parts[i]];
|
|
}
|
|
|
|
current[parts[parts.length - 1]] = obj[key];
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
// Helper function to collect all boolean fields from schema (including nested)
|
|
function collectBooleanFields(schema, prefix = '') {
|
|
const boolFields = [];
|
|
|
|
if (!schema || !schema.properties) return boolFields;
|
|
|
|
Object.entries(schema.properties).forEach(([key, prop]) => {
|
|
const fullKey = prefix ? `${prefix}.${key}` : key;
|
|
|
|
if (prop.type === 'boolean') {
|
|
boolFields.push(fullKey);
|
|
} else if (prop.type === 'object' && prop.properties) {
|
|
boolFields.push(...collectBooleanFields(prop, fullKey));
|
|
}
|
|
});
|
|
|
|
return boolFields;
|
|
}
|
|
|
|
function handlePluginConfigSubmit(e) {
|
|
e.preventDefault();
|
|
console.log('Form submitted');
|
|
|
|
if (!currentPluginConfig) {
|
|
showNotification('Plugin configuration not loaded', 'error');
|
|
return;
|
|
}
|
|
|
|
const pluginId = currentPluginConfig.pluginId;
|
|
const schema = currentPluginConfig.schema;
|
|
const form = e.target;
|
|
|
|
// Fix invalid hidden fields before submission
|
|
// This prevents "invalid form control is not focusable" errors
|
|
const allInputs = form.querySelectorAll('input[type="number"]');
|
|
allInputs.forEach(input => {
|
|
const min = parseFloat(input.getAttribute('min'));
|
|
const max = parseFloat(input.getAttribute('max'));
|
|
const value = parseFloat(input.value);
|
|
|
|
if (!isNaN(value)) {
|
|
if (!isNaN(min) && value < min) {
|
|
input.value = min;
|
|
} else if (!isNaN(max) && value > max) {
|
|
input.value = max;
|
|
}
|
|
}
|
|
});
|
|
|
|
const formData = new FormData(form);
|
|
const flatConfig = {};
|
|
|
|
console.log('Schema loaded:', schema ? 'Yes' : 'No');
|
|
|
|
// Process form data with type conversion (using dot notation for nested fields)
|
|
for (const [key, value] of formData.entries()) {
|
|
// Check if this is a patternProperties hidden input (contains JSON data)
|
|
if (key.endsWith('_data') || key.includes('_data')) {
|
|
try {
|
|
const baseKey = key.replace(/_data$/, '');
|
|
const jsonValue = JSON.parse(value);
|
|
if (typeof jsonValue === 'object' && !Array.isArray(jsonValue)) {
|
|
flatConfig[baseKey] = jsonValue;
|
|
console.log(`PatternProperties field ${baseKey}: parsed JSON object`, jsonValue);
|
|
continue; // Skip normal processing for patternProperties
|
|
}
|
|
} catch (e) {
|
|
// Not valid JSON, continue with normal processing
|
|
}
|
|
}
|
|
|
|
// Skip key_value pair inputs (they're handled by the hidden _data input)
|
|
if (key.includes('[key_') || key.includes('[value_')) {
|
|
continue;
|
|
}
|
|
|
|
// Try to get schema property - handle both dot notation and underscore notation
|
|
let propSchema = getSchemaPropertyType(schema, key);
|
|
let actualKey = key;
|
|
let actualValue = value;
|
|
|
|
// If not found with dots, try converting underscores to dots (for nested fields)
|
|
if (!propSchema && key.includes('_')) {
|
|
const dotKey = key.replace(/_/g, '.');
|
|
propSchema = getSchemaPropertyType(schema, dotKey);
|
|
if (propSchema) {
|
|
// Use the dot notation key for consistency
|
|
actualKey = dotKey;
|
|
actualValue = value;
|
|
}
|
|
}
|
|
|
|
if (propSchema) {
|
|
const propType = propSchema.type;
|
|
|
|
if (propType === 'array') {
|
|
// Check if this is a file upload widget (JSON array)
|
|
if (propSchema['x-widget'] === 'file-upload') {
|
|
// Try to parse as JSON first (for file uploads)
|
|
try {
|
|
// Handle HTML entity encoding (from hidden input)
|
|
let decodedValue = actualValue;
|
|
if (typeof actualValue === 'string') {
|
|
// Decode HTML entities if present
|
|
const tempDiv = document.createElement('div');
|
|
tempDiv.innerHTML = actualValue;
|
|
decodedValue = tempDiv.textContent || tempDiv.innerText || actualValue;
|
|
}
|
|
|
|
const jsonValue = JSON.parse(decodedValue);
|
|
if (Array.isArray(jsonValue)) {
|
|
flatConfig[actualKey] = jsonValue;
|
|
console.log(`File upload array field ${actualKey}: parsed JSON array with ${jsonValue.length} items`);
|
|
} else {
|
|
// Fallback to comma-separated
|
|
const arrayValue = decodedValue ? decodedValue.split(',').map(v => v.trim()).filter(v => v) : [];
|
|
flatConfig[actualKey] = arrayValue;
|
|
}
|
|
} catch (e) {
|
|
// Not JSON, use comma-separated
|
|
const arrayValue = actualValue ? actualValue.split(',').map(v => v.trim()).filter(v => v) : [];
|
|
flatConfig[actualKey] = arrayValue;
|
|
console.log(`Array field ${actualKey}: "${actualValue}" -> `, arrayValue);
|
|
}
|
|
} else {
|
|
// Regular array: convert comma-separated string to array
|
|
const arrayValue = actualValue ? actualValue.split(',').map(v => v.trim()).filter(v => v) : [];
|
|
flatConfig[actualKey] = arrayValue;
|
|
console.log(`Array field ${actualKey}: "${actualValue}" -> `, arrayValue);
|
|
}
|
|
} else if (propType === 'integer') {
|
|
flatConfig[actualKey] = parseInt(actualValue, 10);
|
|
} else if (propType === 'number') {
|
|
flatConfig[actualKey] = parseFloat(actualValue);
|
|
} else if (propType === 'boolean') {
|
|
const formElement = form.elements[actualKey] || form.elements[key];
|
|
flatConfig[actualKey] = formElement ? formElement.checked : (actualValue === 'true' || actualValue === true);
|
|
} else {
|
|
flatConfig[actualKey] = actualValue;
|
|
}
|
|
} else {
|
|
// No schema, try to infer type
|
|
// Check if value looks like a JSON string (starts with [ or {)
|
|
if (typeof actualValue === 'string' && (actualValue.trim().startsWith('[') || actualValue.trim().startsWith('{'))) {
|
|
try {
|
|
// Handle HTML entity encoding
|
|
let decodedValue = actualValue;
|
|
const tempDiv = document.createElement('div');
|
|
tempDiv.innerHTML = actualValue;
|
|
decodedValue = tempDiv.textContent || tempDiv.innerText || actualValue;
|
|
|
|
const parsed = JSON.parse(decodedValue);
|
|
flatConfig[actualKey] = parsed;
|
|
console.log(`No schema for ${actualKey}, but parsed as JSON:`, parsed);
|
|
} catch (e) {
|
|
// Not valid JSON, save as string
|
|
flatConfig[actualKey] = actualValue;
|
|
}
|
|
} else {
|
|
const formElement = form.elements[actualKey] || form.elements[key];
|
|
if (formElement && formElement.type === 'checkbox') {
|
|
flatConfig[actualKey] = formElement.checked;
|
|
} else {
|
|
flatConfig[actualKey] = actualValue;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Handle unchecked checkboxes (not in FormData) - including nested ones
|
|
if (schema && schema.properties) {
|
|
const allBoolFields = collectBooleanFields(schema);
|
|
allBoolFields.forEach(key => {
|
|
if (!(key in flatConfig)) {
|
|
flatConfig[key] = false;
|
|
}
|
|
});
|
|
}
|
|
|
|
// Convert dot notation to nested object
|
|
const config = dotToNested(flatConfig);
|
|
|
|
console.log('Flat config:', flatConfig);
|
|
console.log('Nested config to save:', config);
|
|
|
|
// Save the configuration
|
|
fetch('/api/v3/plugins/config', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
plugin_id: pluginId,
|
|
config: config
|
|
})
|
|
})
|
|
.then(response => response.json())
|
|
.then(data => {
|
|
if (data.status === 'success') {
|
|
// Hide validation errors on success
|
|
displayValidationErrors([]);
|
|
showNotification('Configuration saved successfully', 'success');
|
|
closePluginConfigModal();
|
|
loadInstalledPlugins(); // Refresh to show updated config
|
|
} else {
|
|
// Display validation errors if present
|
|
if (data.validation_errors && Array.isArray(data.validation_errors)) {
|
|
displayValidationErrors(data.validation_errors);
|
|
}
|
|
showNotification('Error saving configuration: ' + data.message, 'error');
|
|
}
|
|
})
|
|
.catch(error => {
|
|
console.error('Error saving plugin config:', error);
|
|
showNotification('Error saving configuration: ' + error.message, 'error');
|
|
});
|
|
}
|
|
|
|
function generatePluginConfigForm(pluginId, config) {
|
|
console.log('[DEBUG] ===== Generating plugin config form =====');
|
|
console.log('[DEBUG] Plugin ID:', pluginId);
|
|
// Load plugin schema and actions for dynamic form generation
|
|
const installedPluginsPromise = (window.PluginAPI && window.PluginAPI.getInstalledPlugins) ?
|
|
window.PluginAPI.getInstalledPlugins().then(plugins => ({ status: 'success', data: { plugins: plugins } })) :
|
|
fetch(`/api/v3/plugins/installed`).then(r => r.json());
|
|
|
|
return Promise.all([
|
|
fetch(`/api/v3/plugins/schema?plugin_id=${pluginId}`).then(r => r.json()),
|
|
installedPluginsPromise
|
|
])
|
|
.then(([schemaData, pluginsData]) => {
|
|
console.log('[DEBUG] Schema data received:', schemaData.status);
|
|
|
|
// Get plugin info including web_ui_actions
|
|
let pluginInfo = null;
|
|
if (pluginsData.status === 'success' && pluginsData.data && pluginsData.data.plugins) {
|
|
pluginInfo = pluginsData.data.plugins.find(p => p.id === pluginId);
|
|
console.log('[DEBUG] Plugin info found:', pluginInfo ? 'yes' : 'no');
|
|
if (pluginInfo) {
|
|
console.log('[DEBUG] Plugin info keys:', Object.keys(pluginInfo));
|
|
console.log('[DEBUG] web_ui_actions in pluginInfo:', 'web_ui_actions' in pluginInfo);
|
|
console.log('[DEBUG] web_ui_actions value:', pluginInfo.web_ui_actions);
|
|
}
|
|
} else {
|
|
console.log('[DEBUG] pluginsData status:', pluginsData.status);
|
|
}
|
|
const webUiActions = pluginInfo ? (pluginInfo.web_ui_actions || []) : [];
|
|
console.log('[DEBUG] Final webUiActions:', webUiActions, 'length:', webUiActions.length);
|
|
|
|
if (schemaData.status === 'success' && schemaData.data.schema) {
|
|
console.log('[DEBUG] Schema has properties:', Object.keys(schemaData.data.schema.properties || {}));
|
|
// Store plugin ID, schema, and actions for form submission
|
|
currentPluginConfig = {
|
|
pluginId: pluginId,
|
|
schema: schemaData.data.schema,
|
|
webUiActions: webUiActions
|
|
};
|
|
// Also update state
|
|
currentPluginConfigState.schema = schemaData.data.schema;
|
|
console.log('[DEBUG] Calling generateFormFromSchema...');
|
|
return generateFormFromSchema(schemaData.data.schema, config, webUiActions);
|
|
} else {
|
|
// Fallback to simple form if no schema
|
|
currentPluginConfig = { pluginId: pluginId, schema: null, webUiActions: webUiActions };
|
|
return generateSimpleConfigForm(config, webUiActions);
|
|
}
|
|
})
|
|
.catch(error => {
|
|
console.error('Error loading schema:', error);
|
|
currentPluginConfig = { pluginId: pluginId, schema: null, webUiActions: [] };
|
|
return generateSimpleConfigForm(config, []);
|
|
});
|
|
}
|
|
|
|
// Helper to flatten nested config for form display (converts {nfl: {enabled: true}} to {'nfl.enabled': true})
|
|
function flattenConfig(obj, prefix = '') {
|
|
let result = {};
|
|
|
|
for (const key in obj) {
|
|
const value = obj[key];
|
|
const fullKey = prefix ? `${prefix}.${key}` : key;
|
|
|
|
if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
|
|
// Recursively flatten nested objects
|
|
Object.assign(result, flattenConfig(value, fullKey));
|
|
} else {
|
|
result[fullKey] = value;
|
|
}
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
// Generate field HTML for a single property (used recursively)
|
|
function 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 logging for categories field
|
|
if (key === 'categories') {
|
|
console.log(`[DEBUG] Processing categories field:`, {
|
|
type: prop.type,
|
|
hasAdditionalProperties: !!(prop.additionalProperties),
|
|
additionalPropertiesType: prop.additionalProperties?.type,
|
|
hasProperties: !!(prop.properties),
|
|
allKeys: Object.keys(prop)
|
|
});
|
|
}
|
|
|
|
// Handle patternProperties objects (dynamic key-value pairs like custom_feeds, feed_logo_map)
|
|
if (prop.type === 'object' && prop.patternProperties && !prop.properties) {
|
|
const fieldId = fullKey.replace(/\./g, '_');
|
|
const currentValue = value || {};
|
|
const patternProp = Object.values(prop.patternProperties)[0]; // Get the pattern property schema
|
|
const valueType = patternProp.type || 'string';
|
|
const maxProperties = prop.maxProperties || 50;
|
|
const entries = Object.entries(currentValue);
|
|
|
|
html += `
|
|
<div class="key-value-pairs-container">
|
|
<div class="mb-2">
|
|
<p class="text-sm text-gray-600 mb-2">${description || 'Add key-value pairs'}</p>
|
|
<div id="${fieldId}_pairs" class="space-y-2">
|
|
`;
|
|
|
|
// Render existing pairs
|
|
entries.forEach(([pairKey, pairValue], index) => {
|
|
html += `
|
|
<div class="flex items-center gap-2 key-value-pair" data-index="${index}">
|
|
<input type="text"
|
|
name="${fullKey}[key_${index}]"
|
|
value="${pairKey}"
|
|
placeholder="Key"
|
|
class="flex-1 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"
|
|
data-key-index="${index}">
|
|
<input type="${valueType === 'string' ? 'text' : valueType === 'number' || valueType === 'integer' ? 'number' : 'text'}"
|
|
name="${fullKey}[value_${index}]"
|
|
value="${pairValue}"
|
|
placeholder="Value"
|
|
class="flex-1 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"
|
|
data-value-index="${index}">
|
|
<button type="button"
|
|
onclick="removeKeyValuePair('${fieldId}', ${index})"
|
|
class="px-3 py-2 text-red-600 hover:text-red-800 hover:bg-red-50 rounded-md transition-colors"
|
|
title="Remove">
|
|
<i class="fas fa-trash"></i>
|
|
</button>
|
|
</div>
|
|
`;
|
|
});
|
|
|
|
html += `
|
|
</div>
|
|
<button type="button"
|
|
onclick="addKeyValuePair('${fieldId}', '${fullKey}', ${maxProperties})"
|
|
class="mt-2 px-4 py-2 text-sm bg-blue-600 hover:bg-blue-700 text-white rounded-md transition-colors"
|
|
${entries.length >= maxProperties ? 'disabled style="opacity: 0.5; cursor: not-allowed;"' : ''}>
|
|
<i class="fas fa-plus mr-1"></i> Add Entry
|
|
</button>
|
|
<input type="hidden" id="${fieldId}_data" name="${fullKey}_data" value='${JSON.stringify(currentValue).replace(/'/g, "'")}'>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
return html;
|
|
}
|
|
|
|
// Handle objects with additionalProperties (dynamic keys with object values, like categories)
|
|
// Must have additionalProperties, no top-level properties, and additionalProperties must be an object type
|
|
const hasAdditionalProperties = prop.type === 'object' &&
|
|
(prop.properties === undefined || prop.properties === null) && // Explicitly exclude objects with properties (those use nested handler)
|
|
prop.additionalProperties &&
|
|
typeof prop.additionalProperties === 'object' &&
|
|
prop.additionalProperties !== null &&
|
|
prop.additionalProperties.type === 'object' &&
|
|
!prop.patternProperties; // Also exclude patternProperties objects
|
|
|
|
// Debug logging for categories field specifically
|
|
if (key === 'categories') {
|
|
console.log(`[DEBUG] Categories field check:`, {
|
|
type: prop.type,
|
|
hasProperties: !!prop.properties,
|
|
hasAdditionalProperties: !!prop.additionalProperties,
|
|
additionalPropertiesType: prop.additionalProperties?.type,
|
|
additionalPropertiesIsObject: typeof prop.additionalProperties === 'object',
|
|
matchesCondition: hasAdditionalProperties,
|
|
allPropKeys: Object.keys(prop)
|
|
});
|
|
}
|
|
|
|
if (hasAdditionalProperties) {
|
|
const fieldId = fullKey.replace(/\./g, '_');
|
|
const currentValue = value || {};
|
|
const categorySchema = prop.additionalProperties;
|
|
const entries = Object.entries(currentValue);
|
|
|
|
console.log(`[DEBUG] Rendering additionalProperties object for ${fullKey}:`, {
|
|
entries: entries.length,
|
|
keys: Object.keys(currentValue)
|
|
});
|
|
|
|
html += `
|
|
<div class="categories-container mb-4">
|
|
<div class="mb-4">
|
|
<h4 class="text-lg font-semibold text-gray-900 mb-2">${label}</h4>
|
|
${description ? `<p class="text-sm text-gray-600 mb-3">${description}</p>` : ''}
|
|
<div id="${fieldId}_categories" class="space-y-3">
|
|
`;
|
|
|
|
// Render each category
|
|
entries.forEach(([categoryKey, categoryValue]) => {
|
|
const categoryId = `${fieldId}_${categoryKey}`;
|
|
// Ensure categoryValue is an object
|
|
const catValue = typeof categoryValue === 'object' && categoryValue !== null ? categoryValue : {};
|
|
const enabled = catValue.enabled !== undefined ? catValue.enabled : (categorySchema.properties?.enabled?.default !== undefined ? categorySchema.properties.enabled.default : true);
|
|
// Safely extract string values, ensuring they're strings
|
|
const dataFile = (typeof catValue.data_file === 'string' ? catValue.data_file : '') || '';
|
|
const displayName = (typeof catValue.display_name === 'string' ? catValue.display_name : '') || categoryKey.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase());
|
|
|
|
html += `
|
|
<div class="category-item border border-gray-300 rounded-lg p-4 bg-white">
|
|
<div class="flex items-center justify-between mb-3">
|
|
<div class="flex items-center gap-3">
|
|
<label class="flex items-center cursor-pointer">
|
|
<input type="checkbox"
|
|
name="${fullKey}.${categoryKey}.enabled"
|
|
${enabled ? 'checked' : ''}
|
|
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded category-enabled-toggle"
|
|
data-category-key="${categoryKey}">
|
|
<span class="ml-2 font-medium text-gray-900">${escapeHtml(displayName)}</span>
|
|
</label>
|
|
</div>
|
|
<span class="text-xs text-gray-500 font-mono">${escapeHtml(categoryKey)}</span>
|
|
</div>
|
|
<div class="space-y-2 text-sm">
|
|
<div>
|
|
<label class="block text-xs font-medium text-gray-700 mb-1">Data File</label>
|
|
<input type="text"
|
|
name="${fullKey}.${categoryKey}.data_file"
|
|
value="${escapeHtml(dataFile)}"
|
|
readonly
|
|
class="w-full px-2 py-1 border border-gray-200 rounded bg-gray-50 text-gray-600 text-xs font-mono">
|
|
</div>
|
|
<div>
|
|
<label class="block text-xs font-medium text-gray-700 mb-1">Display Name</label>
|
|
<input type="text"
|
|
name="${fullKey}.${categoryKey}.display_name"
|
|
value="${escapeHtml(displayName)}"
|
|
class="w-full px-2 py-1 border border-gray-300 rounded text-xs">
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
});
|
|
|
|
if (entries.length === 0) {
|
|
html += `
|
|
<div class="text-center py-4 text-sm text-gray-500">
|
|
<i class="fas fa-info-circle mr-2"></i>
|
|
No categories configured. Use the File Manager below to add JSON files.
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
html += `
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
return html;
|
|
}
|
|
|
|
// Handle nested objects with known properties
|
|
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 rounded-t-lg"
|
|
onclick="toggleNestedSection('${sectionId}', event); return false;"
|
|
data-section-id="${sectionId}">
|
|
<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 rounded-b-lg" 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]) => {
|
|
const nestedValue = nestedConfig[nestedKey] !== undefined ? nestedConfig[nestedKey] : nestedProp.default;
|
|
console.log(`[DEBUG] Processing nested field ${fullKey}.${nestedKey}:`, {
|
|
type: nestedProp.type,
|
|
hasXWidget: nestedProp.hasOwnProperty('x-widget'),
|
|
xWidget: nestedProp['x-widget'],
|
|
allKeys: Object.keys(nestedProp)
|
|
});
|
|
html += generateFieldHtml(nestedKey, nestedProp, nestedValue, fullKey);
|
|
});
|
|
|
|
html += `
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
// Add extra spacing after nested sections to prevent overlap with next section
|
|
html += `<div class="mb-4" style="clear: both;"></div>`;
|
|
|
|
return html;
|
|
}
|
|
|
|
// Regular (non-nested) field
|
|
html += `
|
|
<div class="form-group">
|
|
<label for="${fullKey}" 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">
|
|
<input type="checkbox" id="${fullKey}" name="${fullKey}" ${value ? 'checked' : ''} class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
|
|
<span class="ml-2 text-sm">Enabled</span>
|
|
</label>
|
|
`;
|
|
} else if (prop.type === 'number' || prop.type === 'integer') {
|
|
const min = prop.minimum !== undefined ? `min="${prop.minimum}"` : '';
|
|
const max = prop.maximum !== undefined ? `max="${prop.maximum}"` : '';
|
|
const step = prop.type === 'integer' ? 'step="1"' : 'step="any"';
|
|
|
|
// Ensure value respects min/max constraints
|
|
let fieldValue = value !== undefined ? value : (prop.default !== undefined ? prop.default : '');
|
|
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;
|
|
}
|
|
}
|
|
}
|
|
|
|
// If still empty and we have a default, use it
|
|
if (fieldValue === '' && prop.default !== undefined) {
|
|
fieldValue = prop.default;
|
|
}
|
|
|
|
html += `
|
|
<input type="number" id="${fullKey}" name="${fullKey}" value="${fieldValue}" ${min} ${max} ${step} 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 bg-white text-black placeholder:text-gray-500">
|
|
`;
|
|
} else if (prop.type === 'array') {
|
|
// Check if this is a file upload widget - try multiple ways to access x-widget
|
|
const hasXWidget = prop.hasOwnProperty('x-widget');
|
|
const xWidgetValue = prop['x-widget'];
|
|
const xWidgetValue2 = prop['x-widget'] || prop['x_widget'] || prop.xWidget;
|
|
|
|
console.log(`[DEBUG] Array field ${fullKey}:`, {
|
|
type: prop.type,
|
|
hasXWidget: hasXWidget,
|
|
'x-widget': xWidgetValue,
|
|
'x-widget (alt)': xWidgetValue2,
|
|
'x-upload-config': prop['x-upload-config'],
|
|
propKeys: Object.keys(prop),
|
|
propString: JSON.stringify(prop),
|
|
value: value
|
|
});
|
|
|
|
// Check for file-upload widget - be more defensive
|
|
if (xWidgetValue === 'file-upload' || xWidgetValue2 === 'file-upload') {
|
|
console.log(`[DEBUG] ✅ Detected file-upload widget for ${fullKey} - rendering upload zone`);
|
|
const uploadConfig = prop['x-upload-config'] || {};
|
|
const pluginId = uploadConfig.plugin_id || currentPluginConfig?.pluginId || 'static-image';
|
|
const maxFiles = uploadConfig.max_files || 10;
|
|
const fileType = uploadConfig.file_type || 'image'; // 'image' or 'json'
|
|
const allowedTypes = uploadConfig.allowed_types || (fileType === 'json' ? ['application/json'] : ['image/png', 'image/jpeg', 'image/bmp', 'image/gif']);
|
|
const maxSizeMB = uploadConfig.max_size_mb || 5;
|
|
const customUploadEndpoint = uploadConfig.endpoint; // Custom endpoint if specified
|
|
const customDeleteEndpoint = uploadConfig.delete_endpoint; // Custom delete endpoint if specified
|
|
|
|
const currentFiles = Array.isArray(value) ? value : [];
|
|
const fieldId = fullKey.replace(/\./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="handleFileDrop(event, '${fieldId}')"
|
|
ondragover="event.preventDefault()"
|
|
onclick="document.getElementById('${fieldId}_file_input').click()">
|
|
<input type="file"
|
|
id="${fieldId}_file_input"
|
|
multiple
|
|
accept="${allowedTypes.join(',')}"
|
|
style="display: none;"
|
|
onchange="handleFileSelect(event, '${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 ${fileType === 'json' ? 'JSON files' : 'images'} here or click to browse</p>
|
|
<p class="text-xs text-gray-500 mt-1">Max ${maxFiles} files, ${maxSizeMB}MB each ${fileType === 'json' ? '(JSON)' : '(PNG, JPG, GIF, BMP)'}</p>
|
|
</div>
|
|
|
|
<!-- Uploaded Files List -->
|
|
<div id="${fieldId}_image_list" class="mt-4 space-y-2">
|
|
${currentFiles.map((file, idx) => {
|
|
const fileId = file.id || file.category_name || idx;
|
|
const fileName = file.original_filename || file.filename || (fileType === 'json' ? 'JSON File' : 'Image');
|
|
const entryCount = file.entry_count ? `${file.entry_count} entries` : '';
|
|
|
|
return `
|
|
<div id="file_${fileId}" 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">
|
|
${fileType === 'json' ? `
|
|
<div class="w-16 h-16 bg-blue-100 rounded flex items-center justify-center">
|
|
<i class="fas fa-file-code text-2xl text-blue-600"></i>
|
|
</div>
|
|
` : `
|
|
<img src="/${file.path || ''}"
|
|
alt="${fileName}"
|
|
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">${escapeHtml(fileName)}</p>
|
|
<p class="text-xs text-gray-500">${formatFileSize(file.size || 0)} • ${formatDate(file.uploaded_at)}</p>
|
|
${entryCount ? `<p class="text-xs text-blue-600 mt-1"><i class="fas fa-database mr-1"></i>${entryCount}</p>` : ''}
|
|
${fileType === 'image' && file.schedule ? `
|
|
<p class="text-xs text-blue-600 mt-1">
|
|
<i class="fas fa-clock mr-1"></i>${file.schedule.enabled && file.schedule.mode !== 'always' ? (window.getScheduleSummary ? window.getScheduleSummary(file.schedule) : 'Scheduled') : 'Always shown'}
|
|
</p>
|
|
` : ''}
|
|
</div>
|
|
</div>
|
|
<div class="flex items-center space-x-2 ml-4">
|
|
${fileType === 'image' ? `
|
|
<button type="button"
|
|
onclick="openImageSchedule('${fieldId}', '${fileId}', ${idx})"
|
|
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"
|
|
onclick="deleteUploadedFile('${fieldId}', '${fileId}', '${pluginId}', '${fileType}', ${customDeleteEndpoint ? `'${customDeleteEndpoint}'` : 'null'})"
|
|
class="text-red-600 hover:text-red-800 p-2"
|
|
title="Delete ${fileType === 'json' ? 'file' : 'image'}">
|
|
<i class="fas fa-trash"></i>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
${fileType === 'image' ? `<!-- Schedule widget will be inserted here when opened -->
|
|
<div id="schedule_${fileId}" class="hidden mt-3 pt-3 border-t border-gray-300"></div>
|
|
` : ''}
|
|
</div>
|
|
`;
|
|
}).join('')}
|
|
</div>
|
|
|
|
<!-- Hidden input to store file data -->
|
|
<input type="hidden" id="${fieldId}_images_data" name="${fullKey}" value="${JSON.stringify(currentFiles).replace(/&/g, '&').replace(/"/g, '"').replace(/'/g, ''')}"
|
|
data-upload-endpoint="${customUploadEndpoint || '/api/v3/plugins/assets/upload'}"
|
|
data-file-type="${fileType}">
|
|
</div>
|
|
`;
|
|
} else if (xWidgetValue === 'checkbox-group' || xWidgetValue2 === 'checkbox-group') {
|
|
// Checkbox group widget for multi-select arrays with enum items
|
|
console.log(`[DEBUG] ✅ Detected checkbox-group widget for ${fullKey} - rendering checkboxes`);
|
|
const arrayValue = Array.isArray(value) ? value : (prop.default || []);
|
|
const enumItems = prop.items && prop.items.enum ? prop.items.enum : [];
|
|
const xOptions = prop['x-options'] || {};
|
|
const labels = xOptions.labels || {};
|
|
|
|
html += `<div class="mt-1 space-y-2">`;
|
|
enumItems.forEach(option => {
|
|
const isChecked = arrayValue.includes(option);
|
|
const label = labels[option] || option.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase());
|
|
const checkboxId = `${fullKey.replace(/\./g, '_')}_${option}`;
|
|
html += `
|
|
<label class="flex items-center">
|
|
<input type="checkbox"
|
|
id="${checkboxId}"
|
|
name="${fullKey}[]"
|
|
value="${option}"
|
|
${isChecked ? 'checked' : ''}
|
|
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
|
|
<span class="ml-2 text-sm text-gray-700">${label}</span>
|
|
</label>
|
|
`;
|
|
});
|
|
html += `</div>`;
|
|
} else {
|
|
// Regular array input
|
|
console.log(`[DEBUG] ❌ NOT a file upload widget for ${fullKey}, using regular array input`);
|
|
// Handle null/undefined values - use default if available
|
|
let arrayValue = '';
|
|
if (value === null || value === undefined) {
|
|
arrayValue = Array.isArray(prop.default) ? prop.default.join(', ') : '';
|
|
} else if (Array.isArray(value)) {
|
|
arrayValue = value.join(', ');
|
|
} else {
|
|
arrayValue = '';
|
|
}
|
|
html += `
|
|
<input type="text" id="${fullKey}" 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 bg-white text-black placeholder:text-gray-500">
|
|
<p class="text-sm text-gray-600 mt-1">Enter values separated by commas</p>
|
|
`;
|
|
}
|
|
} else if (prop.enum) {
|
|
html += `<select id="${fullKey}" 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 bg-white text-black">`;
|
|
prop.enum.forEach(option => {
|
|
const selected = value === option ? 'selected' : '';
|
|
html += `<option value="${option}" ${selected}>${option}</option>`;
|
|
});
|
|
html += `</select>`;
|
|
} else if (prop['x-widget'] === 'custom-html') {
|
|
// Custom HTML widget - load HTML from plugin directory
|
|
const htmlFile = prop['x-html-file'];
|
|
const pluginId = currentPluginConfig?.pluginId || window.currentPluginConfig?.pluginId || '';
|
|
const fieldId = fullKey.replace(/\./g, '_');
|
|
|
|
console.log(`[Custom HTML Widget] Generating widget for ${fullKey}:`, {
|
|
htmlFile,
|
|
pluginId,
|
|
fieldId,
|
|
hasPluginId: !!pluginId
|
|
});
|
|
|
|
if (htmlFile && pluginId) {
|
|
html += `
|
|
<div id="${fieldId}_custom_html"
|
|
data-plugin-id="${pluginId}"
|
|
data-html-file="${htmlFile}"
|
|
class="custom-html-widget">
|
|
<div class="animate-pulse text-center py-4">
|
|
<i class="fas fa-spinner fa-spin text-gray-400"></i>
|
|
<p class="text-sm text-gray-500 mt-2">Loading file manager...</p>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
// Load HTML asynchronously
|
|
setTimeout(() => {
|
|
loadCustomHtmlWidget(fieldId, pluginId, htmlFile);
|
|
}, 100);
|
|
} else {
|
|
console.error(`[Custom HTML Widget] Missing configuration for ${fullKey}:`, {
|
|
htmlFile,
|
|
pluginId,
|
|
currentPluginConfig: currentPluginConfig?.pluginId,
|
|
windowPluginConfig: window.currentPluginConfig?.pluginId
|
|
});
|
|
html += `
|
|
<div class="text-sm text-red-600 p-4 border border-red-200 rounded">
|
|
<i class="fas fa-exclamation-triangle mr-1"></i>
|
|
Custom HTML widget configuration error: missing html-file or plugin-id
|
|
<br><small>htmlFile: ${htmlFile || 'missing'}, pluginId: ${pluginId || 'missing'}</small>
|
|
</div>
|
|
`;
|
|
}
|
|
} else if (prop.type === 'object') {
|
|
// Fallback for objects that don't match any special case - render as JSON textarea
|
|
console.warn(`[DEBUG] Object field ${fullKey} doesn't match any special handler, rendering as JSON textarea`);
|
|
const jsonValue = typeof value === 'object' && value !== null ? JSON.stringify(value, null, 2) : (value || '{}');
|
|
html += `
|
|
<textarea id="${fullKey}" name="${fullKey}" rows="8" 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 font-mono text-xs bg-white text-black" style="font-family: 'Courier New', monospace;">${escapeHtml(jsonValue)}</textarea>
|
|
<p class="text-sm text-gray-600 mt-1">Edit as JSON object</p>
|
|
`;
|
|
} else {
|
|
// Check if this is a secret field
|
|
const isSecret = prop['x-secret'] === true;
|
|
const inputType = isSecret ? 'password' : 'text';
|
|
const maxLength = prop.maxLength || '';
|
|
const maxLengthAttr = maxLength ? `maxlength="${maxLength}"` : '';
|
|
const secretClass = isSecret ? 'pr-10' : '';
|
|
|
|
html += `
|
|
<div class="relative">
|
|
<input type="${inputType}" id="${fullKey}" 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 bg-white text-black placeholder:text-gray-500 ${secretClass}">
|
|
`;
|
|
|
|
if (isSecret) {
|
|
html += `
|
|
<button type="button" onclick="togglePasswordVisibility('${fullKey}')" class="absolute inset-y-0 right-0 pr-3 flex items-center text-gray-500 hover:text-gray-700">
|
|
<i id="${fullKey}-icon" class="fas fa-eye"></i>
|
|
</button>
|
|
`;
|
|
}
|
|
|
|
html += `</div>`;
|
|
}
|
|
|
|
html += `</div>`;
|
|
|
|
return html;
|
|
}
|
|
|
|
// Load custom HTML widget from plugin directory
|
|
async function loadCustomHtmlWidget(fieldId, pluginId, htmlFile) {
|
|
try {
|
|
const container = document.getElementById(`${fieldId}_custom_html`);
|
|
if (!container) {
|
|
console.warn(`[Custom HTML Widget] Container not found: ${fieldId}_custom_html`);
|
|
return;
|
|
}
|
|
|
|
// Fetch HTML from plugin static files endpoint
|
|
const response = await fetch(`/api/v3/plugins/${pluginId}/static/${htmlFile}`);
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to load custom HTML: ${response.statusText}`);
|
|
}
|
|
|
|
const html = await response.text();
|
|
|
|
// Inject HTML into container
|
|
container.innerHTML = html;
|
|
|
|
// Execute any script tags in the loaded HTML
|
|
const scripts = container.querySelectorAll('script');
|
|
scripts.forEach(oldScript => {
|
|
const newScript = document.createElement('script');
|
|
Array.from(oldScript.attributes).forEach(attr => {
|
|
newScript.setAttribute(attr.name, attr.value);
|
|
});
|
|
newScript.appendChild(document.createTextNode(oldScript.innerHTML));
|
|
oldScript.parentNode.replaceChild(newScript, oldScript);
|
|
});
|
|
|
|
console.log(`[Custom HTML Widget] Loaded ${htmlFile} for plugin ${pluginId}`);
|
|
} catch (error) {
|
|
console.error(`[Custom HTML Widget] Error loading ${htmlFile} for plugin ${pluginId}:`, error);
|
|
const container = document.getElementById(`${fieldId}_custom_html`);
|
|
if (container) {
|
|
container.innerHTML = `
|
|
<div class="text-sm text-red-600 p-4 border border-red-200 rounded">
|
|
<i class="fas fa-exclamation-triangle mr-1"></i>
|
|
Failed to load custom HTML: ${error.message}
|
|
</div>
|
|
`;
|
|
}
|
|
}
|
|
}
|
|
|
|
function generateFormFromSchema(schema, config, webUiActions = []) {
|
|
console.log('[DEBUG] ===== generateFormFromSchema called =====');
|
|
console.log('[DEBUG] Schema properties:', Object.keys(schema.properties || {}));
|
|
console.log('[DEBUG] Web UI Actions:', webUiActions.length);
|
|
let formHtml = '<form id="plugin-config-form" class="space-y-4" novalidate>';
|
|
|
|
if (schema.properties) {
|
|
// 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;
|
|
|
|
let value = config[key] !== undefined ? config[key] : prop.default;
|
|
|
|
// Special handling: use uploaded_files from config if available (populated by backend from disk)
|
|
// No need to populate from categories here since backend does it
|
|
|
|
formHtml += generateFieldHtml(key, prop, value);
|
|
});
|
|
}
|
|
|
|
// Add web UI actions section if plugin defines any
|
|
console.log('[DEBUG] webUiActions:', webUiActions, 'length:', webUiActions ? webUiActions.length : 0);
|
|
if (webUiActions && webUiActions.length > 0) {
|
|
console.log('[DEBUG] Rendering', webUiActions.length, 'actions');
|
|
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';
|
|
|
|
// Map color names to explicit Tailwind classes to ensure they're included
|
|
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' }
|
|
};
|
|
|
|
const colors = colorMap[bgColor] || colorMap['blue'];
|
|
|
|
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})"
|
|
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>
|
|
`;
|
|
} else {
|
|
console.log('[DEBUG] No webUiActions to render');
|
|
}
|
|
|
|
formHtml += `
|
|
<div class="flex justify-end space-x-2 pt-4 border-t border-gray-200">
|
|
<button type="button" onclick="closePluginConfigModal()" class="btn bg-gray-600 hover:bg-gray-700 text-white px-4 py-2 rounded-md">
|
|
Cancel
|
|
</button>
|
|
<button type="submit" class="btn bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-md">
|
|
<i class="fas fa-save mr-2"></i>Save Configuration
|
|
</button>
|
|
</div>
|
|
</form>
|
|
`;
|
|
|
|
return Promise.resolve(formHtml);
|
|
}
|
|
|
|
// Functions to handle patternProperties key-value pairs
|
|
window.addKeyValuePair = function(fieldId, fullKey, maxProperties) {
|
|
const pairsContainer = document.getElementById(fieldId + '_pairs');
|
|
if (!pairsContainer) return;
|
|
|
|
const currentPairs = pairsContainer.querySelectorAll('.key-value-pair');
|
|
if (currentPairs.length >= maxProperties) {
|
|
alert(`Maximum ${maxProperties} entries allowed`);
|
|
return;
|
|
}
|
|
|
|
const newIndex = currentPairs.length;
|
|
const valueType = 'string'; // Default to string, could be determined from schema
|
|
|
|
const pairHtml = `
|
|
<div class="flex items-center gap-2 key-value-pair" data-index="${newIndex}">
|
|
<input type="text"
|
|
name="${fullKey}[key_${newIndex}]"
|
|
value=""
|
|
placeholder="Key"
|
|
class="flex-1 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"
|
|
data-key-index="${newIndex}"
|
|
onchange="updateKeyValuePairData('${fieldId}', '${fullKey}')">
|
|
<input type="text"
|
|
name="${fullKey}[value_${newIndex}]"
|
|
value=""
|
|
placeholder="Value"
|
|
class="flex-1 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"
|
|
data-value-index="${newIndex}"
|
|
onchange="updateKeyValuePairData('${fieldId}', '${fullKey}')">
|
|
<button type="button"
|
|
onclick="removeKeyValuePair('${fieldId}', ${newIndex})"
|
|
class="px-3 py-2 text-red-600 hover:text-red-800 hover:bg-red-50 rounded-md transition-colors"
|
|
title="Remove">
|
|
<i class="fas fa-trash"></i>
|
|
</button>
|
|
</div>
|
|
`;
|
|
|
|
pairsContainer.insertAdjacentHTML('beforeend', pairHtml);
|
|
updateKeyValuePairData(fieldId, fullKey);
|
|
|
|
// Update add button state
|
|
const addButton = pairsContainer.nextElementSibling;
|
|
if (addButton && currentPairs.length + 1 >= maxProperties) {
|
|
addButton.disabled = true;
|
|
addButton.style.opacity = '0.5';
|
|
addButton.style.cursor = 'not-allowed';
|
|
}
|
|
};
|
|
|
|
window.removeKeyValuePair = function(fieldId, index) {
|
|
const pairsContainer = document.getElementById(fieldId + '_pairs');
|
|
if (!pairsContainer) return;
|
|
|
|
const pair = pairsContainer.querySelector(`.key-value-pair[data-index="${index}"]`);
|
|
if (pair) {
|
|
pair.remove();
|
|
// Re-index remaining pairs
|
|
const remainingPairs = pairsContainer.querySelectorAll('.key-value-pair');
|
|
remainingPairs.forEach((p, newIndex) => {
|
|
p.setAttribute('data-index', newIndex);
|
|
const keyInput = p.querySelector('[data-key-index]');
|
|
const valueInput = p.querySelector('[data-value-index]');
|
|
if (keyInput) {
|
|
keyInput.setAttribute('name', keyInput.getAttribute('name').replace(/\[key_\d+\]/, `[key_${newIndex}]`));
|
|
keyInput.setAttribute('data-key-index', newIndex);
|
|
keyInput.setAttribute('onchange', `updateKeyValuePairData('${fieldId}', '${keyInput.getAttribute('name').split('[')[0]}')`);
|
|
}
|
|
if (valueInput) {
|
|
valueInput.setAttribute('name', valueInput.getAttribute('name').replace(/\[value_\d+\]/, `[value_${newIndex}]`));
|
|
valueInput.setAttribute('data-value-index', newIndex);
|
|
valueInput.setAttribute('onchange', `updateKeyValuePairData('${fieldId}', '${valueInput.getAttribute('name').split('[')[0]}')`);
|
|
}
|
|
const removeButton = p.querySelector('button[onclick*="removeKeyValuePair"]');
|
|
if (removeButton) {
|
|
removeButton.setAttribute('onclick', `removeKeyValuePair('${fieldId}', ${newIndex})`);
|
|
}
|
|
});
|
|
const hiddenInput = pairsContainer.closest('.key-value-pairs-container').querySelector('input[type="hidden"]');
|
|
if (hiddenInput) {
|
|
const hiddenName = hiddenInput.getAttribute('name').replace(/_data$/, '');
|
|
updateKeyValuePairData(fieldId, hiddenName);
|
|
}
|
|
|
|
// Update add button state
|
|
const addButton = pairsContainer.nextElementSibling;
|
|
if (addButton) {
|
|
const maxProperties = parseInt(addButton.getAttribute('onclick').match(/\d+/)[0]);
|
|
if (remainingPairs.length < maxProperties) {
|
|
addButton.disabled = false;
|
|
addButton.style.opacity = '1';
|
|
addButton.style.cursor = 'pointer';
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
window.updateKeyValuePairData = function(fieldId, fullKey) {
|
|
const pairsContainer = document.getElementById(fieldId + '_pairs');
|
|
const hiddenInput = document.getElementById(fieldId + '_data');
|
|
if (!pairsContainer || !hiddenInput) return;
|
|
|
|
const pairs = {};
|
|
const keyInputs = pairsContainer.querySelectorAll('[data-key-index]');
|
|
const valueInputs = pairsContainer.querySelectorAll('[data-value-index]');
|
|
|
|
keyInputs.forEach((keyInput, idx) => {
|
|
const key = keyInput.value.trim();
|
|
const valueInput = Array.from(valueInputs).find(v => v.getAttribute('data-value-index') === keyInput.getAttribute('data-key-index'));
|
|
if (key && valueInput) {
|
|
const value = valueInput.value.trim();
|
|
if (value) {
|
|
pairs[key] = value;
|
|
}
|
|
}
|
|
});
|
|
|
|
hiddenInput.value = JSON.stringify(pairs);
|
|
};
|
|
|
|
// Function to toggle nested sections
|
|
window.toggleNestedSection = function(sectionId, event) {
|
|
// Prevent event bubbling if event is provided
|
|
if (event) {
|
|
event.stopPropagation();
|
|
event.preventDefault();
|
|
}
|
|
|
|
const content = document.getElementById(sectionId);
|
|
const icon = document.getElementById(sectionId + '-icon');
|
|
|
|
if (!content || !icon) return;
|
|
|
|
// Prevent multiple simultaneous toggles
|
|
if (content.dataset.toggling === 'true') {
|
|
return;
|
|
}
|
|
|
|
// Mark as toggling
|
|
content.dataset.toggling = 'true';
|
|
|
|
// Check current state before making changes
|
|
const hasCollapsed = content.classList.contains('collapsed');
|
|
const hasExpanded = content.classList.contains('expanded');
|
|
const displayStyle = content.style.display;
|
|
const computedDisplay = window.getComputedStyle(content).display;
|
|
|
|
// Check if content is currently collapsed - prioritize class over display style
|
|
const isCollapsed = hasCollapsed || (!hasExpanded && (displayStyle === 'none' || computedDisplay === 'none'));
|
|
|
|
if (isCollapsed) {
|
|
// Expand the section
|
|
content.classList.remove('collapsed');
|
|
content.classList.add('expanded');
|
|
content.style.display = 'block';
|
|
content.style.overflow = 'hidden'; // Prevent content jumping during animation
|
|
|
|
// CRITICAL FIX: Use setTimeout to ensure browser has time to layout the element
|
|
// When element goes from display:none to display:block, scrollHeight might be 0
|
|
// We need to wait for the browser to calculate the layout
|
|
setTimeout(() => {
|
|
// Force reflow to ensure transition works
|
|
void content.offsetHeight;
|
|
|
|
// Now measure the actual content height after layout
|
|
const scrollHeight = content.scrollHeight;
|
|
if (scrollHeight > 0) {
|
|
content.style.maxHeight = scrollHeight + 'px';
|
|
} else {
|
|
// Fallback: if scrollHeight is still 0, try measuring again after a brief delay
|
|
setTimeout(() => {
|
|
const retryHeight = content.scrollHeight;
|
|
content.style.maxHeight = retryHeight > 0 ? retryHeight + 'px' : '500px';
|
|
}, 10);
|
|
}
|
|
}, 10);
|
|
|
|
icon.classList.remove('fa-chevron-right');
|
|
icon.classList.add('fa-chevron-down');
|
|
|
|
// Allow parent section to show overflow when expanded
|
|
const sectionElement = content.closest('.nested-section');
|
|
if (sectionElement) {
|
|
sectionElement.style.overflow = 'visible';
|
|
}
|
|
|
|
// After animation completes, remove max-height constraint to allow natural expansion
|
|
// This allows parent sections to automatically expand
|
|
setTimeout(() => {
|
|
// Only set to none if still expanded (prevent race condition)
|
|
if (content.classList.contains('expanded') && !content.classList.contains('collapsed')) {
|
|
content.style.maxHeight = 'none';
|
|
content.style.overflow = '';
|
|
}
|
|
// Clear toggling flag
|
|
content.dataset.toggling = 'false';
|
|
}, 320); // Slightly longer than transition duration
|
|
|
|
// Scroll the expanded content into view after a short delay to allow animation
|
|
setTimeout(() => {
|
|
if (sectionElement) {
|
|
// Find the modal container
|
|
const modalContent = sectionElement.closest('.modal-content');
|
|
if (modalContent) {
|
|
// Scroll the section header into view within the modal
|
|
const headerButton = sectionElement.querySelector('button');
|
|
if (headerButton) {
|
|
headerButton.scrollIntoView({ behavior: 'smooth', block: 'start', inline: 'nearest' });
|
|
}
|
|
} else {
|
|
// If not in a modal, just scroll the section
|
|
sectionElement.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
|
}
|
|
}
|
|
}, 350); // Wait for animation to complete
|
|
} else {
|
|
// Collapse the section
|
|
content.classList.add('collapsed');
|
|
content.classList.remove('expanded');
|
|
content.style.overflow = 'hidden'; // Prevent content jumping during animation
|
|
|
|
// Set max-height to current scroll height first (required for smooth animation)
|
|
const currentHeight = content.scrollHeight;
|
|
content.style.maxHeight = currentHeight + 'px';
|
|
|
|
// Force reflow to apply the height
|
|
void content.offsetHeight;
|
|
|
|
// Then animate to 0
|
|
setTimeout(() => {
|
|
content.style.maxHeight = '0';
|
|
}, 10);
|
|
|
|
// Restore parent section overflow when collapsed
|
|
const sectionElement = content.closest('.nested-section');
|
|
if (sectionElement) {
|
|
sectionElement.style.overflow = 'hidden';
|
|
}
|
|
|
|
// Use setTimeout to set display:none after transition completes
|
|
setTimeout(() => {
|
|
if (content.classList.contains('collapsed')) {
|
|
content.style.display = 'none';
|
|
content.style.overflow = '';
|
|
}
|
|
// Clear toggling flag
|
|
content.dataset.toggling = 'false';
|
|
}, 320); // Match the CSS transition duration + small buffer
|
|
icon.classList.remove('fa-chevron-down');
|
|
icon.classList.add('fa-chevron-right');
|
|
}
|
|
}
|
|
|
|
function generateSimpleConfigForm(config, webUiActions = []) {
|
|
console.log('[DEBUG] generateSimpleConfigForm - webUiActions:', webUiActions, 'length:', webUiActions ? webUiActions.length : 0);
|
|
let actionsHtml = '';
|
|
if (webUiActions && webUiActions.length > 0) {
|
|
console.log('[DEBUG] Rendering', webUiActions.length, 'actions in simple form');
|
|
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">
|
|
`;
|
|
|
|
// 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' }
|
|
};
|
|
|
|
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'];
|
|
|
|
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})"
|
|
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 `
|
|
<form id="plugin-config-form" class="space-y-4" novalidate>
|
|
<div class="form-group">
|
|
<label class="block text-sm font-medium text-gray-700 mb-1">Configuration</label>
|
|
<textarea name="config" class="form-control h-32" placeholder="Plugin configuration JSON">${JSON.stringify(config, null, 2)}</textarea>
|
|
</div>
|
|
${actionsHtml}
|
|
<div class="flex justify-end space-x-2">
|
|
<button type="button" onclick="closePluginConfigModal()" class="btn bg-gray-600 hover:bg-gray-700 text-white px-4 py-2">
|
|
Cancel
|
|
</button>
|
|
<button type="submit" class="btn bg-blue-600 hover:bg-blue-700 text-white px-4 py-2">
|
|
Save Configuration
|
|
</button>
|
|
</div>
|
|
</form>
|
|
`;
|
|
}
|
|
|
|
// Plugin config modal state
|
|
let currentPluginConfigState = {
|
|
pluginId: null,
|
|
config: {},
|
|
schema: null,
|
|
jsonEditor: null,
|
|
formData: {}
|
|
};
|
|
|
|
// Initialize JSON editor
|
|
async function initJsonEditor() {
|
|
const textarea = document.getElementById('plugin-config-json-editor');
|
|
if (!textarea) return null;
|
|
|
|
// Lazy load CodeMirror if needed
|
|
if (typeof CodeMirror === 'undefined') {
|
|
if (typeof window.loadCodeMirror === 'function') {
|
|
try {
|
|
await window.loadCodeMirror();
|
|
} catch (error) {
|
|
console.error('Failed to load CodeMirror:', error);
|
|
showNotification('JSON editor not available. Please refresh the page.', 'error');
|
|
return null;
|
|
}
|
|
} else {
|
|
console.error('CodeMirror not loaded and loadCodeMirror not available. Please refresh the page.');
|
|
showNotification('JSON editor not available. Please refresh the page.', 'error');
|
|
return null;
|
|
}
|
|
}
|
|
|
|
if (currentPluginConfigState.jsonEditor) {
|
|
currentPluginConfigState.jsonEditor.toTextArea();
|
|
currentPluginConfigState.jsonEditor = null;
|
|
}
|
|
|
|
const editor = CodeMirror.fromTextArea(textarea, {
|
|
mode: 'application/json',
|
|
theme: 'monokai',
|
|
lineNumbers: true,
|
|
lineWrapping: true,
|
|
indentUnit: 2,
|
|
tabSize: 2,
|
|
autoCloseBrackets: true,
|
|
matchBrackets: true,
|
|
foldGutter: true,
|
|
gutters: ['CodeMirror-linenumbers', 'CodeMirror-foldgutter']
|
|
});
|
|
|
|
// Validate JSON on change
|
|
editor.on('change', function() {
|
|
const value = editor.getValue();
|
|
try {
|
|
JSON.parse(value);
|
|
editor.setOption('class', '');
|
|
} catch (e) {
|
|
editor.setOption('class', 'cm-error');
|
|
}
|
|
});
|
|
|
|
return editor;
|
|
}
|
|
|
|
// Switch between form and JSON views
|
|
function switchPluginConfigView(view) {
|
|
const formView = document.getElementById('plugin-config-form-view');
|
|
const jsonView = document.getElementById('plugin-config-json-view');
|
|
const formBtn = document.getElementById('view-toggle-form');
|
|
const jsonBtn = document.getElementById('view-toggle-json');
|
|
|
|
if (view === 'json') {
|
|
formView.classList.add('hidden');
|
|
jsonView.classList.remove('hidden');
|
|
formBtn.classList.remove('active', 'bg-blue-600', 'text-white');
|
|
formBtn.classList.add('text-gray-700', 'hover:bg-gray-200');
|
|
jsonBtn.classList.add('active', 'bg-blue-600', 'text-white');
|
|
jsonBtn.classList.remove('text-gray-700', 'hover:bg-gray-200');
|
|
|
|
// Sync form data to JSON editor
|
|
syncFormToJson();
|
|
|
|
// Initialize editor if not already done
|
|
if (!currentPluginConfigState.jsonEditor) {
|
|
// Small delay to ensure textarea is visible, then load CodeMirror and initialize
|
|
setTimeout(async () => {
|
|
currentPluginConfigState.jsonEditor = await initJsonEditor();
|
|
if (currentPluginConfigState.jsonEditor) {
|
|
const jsonText = JSON.stringify(currentPluginConfigState.config, null, 2);
|
|
currentPluginConfigState.jsonEditor.setValue(jsonText);
|
|
currentPluginConfigState.jsonEditor.refresh();
|
|
}
|
|
}, 50);
|
|
} else {
|
|
// Update editor content if already initialized
|
|
const jsonText = JSON.stringify(currentPluginConfigState.config, null, 2);
|
|
currentPluginConfigState.jsonEditor.setValue(jsonText);
|
|
currentPluginConfigState.jsonEditor.refresh();
|
|
}
|
|
} else {
|
|
jsonView.classList.add('hidden');
|
|
formView.classList.remove('hidden');
|
|
jsonBtn.classList.remove('active', 'bg-blue-600', 'text-white');
|
|
jsonBtn.classList.add('text-gray-700', 'hover:bg-gray-200');
|
|
formBtn.classList.add('active', 'bg-blue-600', 'text-white');
|
|
formBtn.classList.remove('text-gray-700', 'hover:bg-gray-200');
|
|
|
|
// Sync JSON to form if JSON was edited
|
|
syncJsonToForm();
|
|
}
|
|
}
|
|
|
|
// Sync form data to JSON config
|
|
function syncFormToJson() {
|
|
const form = document.getElementById('plugin-config-form');
|
|
if (!form) return;
|
|
|
|
const formData = new FormData(form);
|
|
const config = {};
|
|
|
|
// Get schema for type conversion
|
|
const schema = currentPluginConfigState.schema;
|
|
|
|
for (let [key, value] of formData.entries()) {
|
|
if (key === 'enabled') continue; // Skip enabled, managed separately
|
|
|
|
// Handle nested keys (dot notation)
|
|
const keys = key.split('.');
|
|
let current = config;
|
|
for (let i = 0; i < keys.length - 1; i++) {
|
|
if (!current[keys[i]]) {
|
|
current[keys[i]] = {};
|
|
}
|
|
current = current[keys[i]];
|
|
}
|
|
|
|
const finalKey = keys[keys.length - 1];
|
|
const prop = schema?.properties?.[finalKey] || (keys.length > 1 ? null : schema?.properties?.[key]);
|
|
|
|
// Type conversion based on schema
|
|
if (prop?.type === 'array') {
|
|
current[finalKey] = value.split(',').map(item => item.trim()).filter(item => item.length > 0);
|
|
} else if (prop?.type === 'integer' || key === 'display_duration') {
|
|
current[finalKey] = parseInt(value) || 0;
|
|
} else if (prop?.type === 'number') {
|
|
current[finalKey] = parseFloat(value) || 0;
|
|
} else if (prop?.type === 'boolean') {
|
|
current[finalKey] = value === 'true' || value === true;
|
|
} else {
|
|
current[finalKey] = value;
|
|
}
|
|
}
|
|
|
|
// Deep merge with existing config to preserve nested structures
|
|
function deepMerge(target, source) {
|
|
for (const key in source) {
|
|
if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key])) {
|
|
if (!target[key] || typeof target[key] !== 'object' || Array.isArray(target[key])) {
|
|
target[key] = {};
|
|
}
|
|
deepMerge(target[key], source[key]);
|
|
} else {
|
|
target[key] = source[key];
|
|
}
|
|
}
|
|
return target;
|
|
}
|
|
|
|
// Deep merge new form data into existing config
|
|
currentPluginConfigState.config = deepMerge(
|
|
JSON.parse(JSON.stringify(currentPluginConfigState.config)), // Deep clone
|
|
config
|
|
);
|
|
}
|
|
|
|
// Sync JSON editor content to form
|
|
function syncJsonToForm() {
|
|
if (!currentPluginConfigState.jsonEditor) return;
|
|
|
|
try {
|
|
const jsonText = currentPluginConfigState.jsonEditor.getValue();
|
|
const config = JSON.parse(jsonText);
|
|
currentPluginConfigState.config = config;
|
|
|
|
// Update form fields (this is complex, so we'll reload the form)
|
|
// For now, just update the config state - form will be regenerated on next open
|
|
console.log('JSON synced to config state');
|
|
} catch (e) {
|
|
console.error('Invalid JSON in editor:', e);
|
|
showNotification('Invalid JSON in editor. Please fix errors before switching views.', 'error');
|
|
}
|
|
}
|
|
|
|
// Reset plugin config to defaults
|
|
async function resetPluginConfigToDefaults() {
|
|
if (!currentPluginConfigState.pluginId) {
|
|
showNotification('No plugin selected', 'error');
|
|
return;
|
|
}
|
|
|
|
if (!confirm('Are you sure you want to reset this plugin configuration to defaults? This will replace all current settings.')) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const response = await fetch('/api/v3/plugins/config/reset', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
plugin_id: currentPluginConfigState.pluginId,
|
|
preserve_secrets: true
|
|
})
|
|
});
|
|
|
|
const data = await response.json();
|
|
|
|
if (data.status === 'success') {
|
|
showNotification(data.message, 'success');
|
|
|
|
// Reload the config form with defaults
|
|
const newConfig = data.data?.config || {};
|
|
currentPluginConfigState.config = newConfig;
|
|
|
|
// Regenerate form
|
|
const content = document.getElementById('plugin-config-content');
|
|
if (content) {
|
|
content.innerHTML = '<div class="flex items-center justify-center py-8"><i class="fas fa-spinner fa-spin text-2xl text-blue-600"></i></div>';
|
|
generatePluginConfigForm(currentPluginConfigState.pluginId, newConfig)
|
|
.then(formHtml => {
|
|
content.innerHTML = formHtml;
|
|
const form = document.getElementById('plugin-config-form');
|
|
if (form) {
|
|
form.addEventListener('submit', handlePluginConfigSubmit);
|
|
}
|
|
});
|
|
}
|
|
|
|
// Update JSON editor if it's visible
|
|
if (currentPluginConfigState.jsonEditor) {
|
|
const jsonText = JSON.stringify(newConfig, null, 2);
|
|
currentPluginConfigState.jsonEditor.setValue(jsonText);
|
|
}
|
|
} else {
|
|
showNotification(data.message || 'Failed to reset configuration', 'error');
|
|
}
|
|
} catch (error) {
|
|
console.error('Error resetting config:', error);
|
|
showNotification('Error resetting configuration: ' + error.message, 'error');
|
|
}
|
|
}
|
|
|
|
// Display validation errors
|
|
function displayValidationErrors(errors) {
|
|
const errorContainer = document.getElementById('plugin-config-validation-errors');
|
|
const errorList = document.getElementById('validation-errors-list');
|
|
|
|
if (!errorContainer || !errorList) return;
|
|
|
|
if (errors && errors.length > 0) {
|
|
errorContainer.classList.remove('hidden');
|
|
errorList.innerHTML = errors.map(error => `<li>${escapeHtml(error)}</li>`).join('');
|
|
} else {
|
|
errorContainer.classList.add('hidden');
|
|
errorList.innerHTML = '';
|
|
}
|
|
}
|
|
|
|
// Save configuration from JSON editor
|
|
async function saveConfigFromJsonEditor() {
|
|
if (!currentPluginConfigState.jsonEditor || !currentPluginConfigState.pluginId) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const jsonText = currentPluginConfigState.jsonEditor.getValue();
|
|
const config = JSON.parse(jsonText);
|
|
|
|
// Update state
|
|
currentPluginConfigState.config = config;
|
|
|
|
// Save the configuration (will handle validation errors)
|
|
savePluginConfiguration(currentPluginConfigState.pluginId, config);
|
|
} catch (e) {
|
|
console.error('Error saving JSON config:', e);
|
|
if (e instanceof SyntaxError) {
|
|
showNotification('Invalid JSON. Please fix syntax errors before saving.', 'error');
|
|
displayValidationErrors([`JSON Syntax Error: ${e.message}`]);
|
|
} else {
|
|
showNotification('Error saving configuration: ' + e.message, 'error');
|
|
}
|
|
}
|
|
}
|
|
|
|
window.closePluginConfigModal = function() {
|
|
const modal = document.getElementById('plugin-config-modal');
|
|
modal.style.display = 'none';
|
|
|
|
// Clean up JSON editor
|
|
if (currentPluginConfigState.jsonEditor) {
|
|
currentPluginConfigState.jsonEditor.toTextArea();
|
|
currentPluginConfigState.jsonEditor = null;
|
|
}
|
|
|
|
// Reset state
|
|
currentPluginConfig = null;
|
|
currentPluginConfigState.pluginId = null;
|
|
currentPluginConfigState.config = {};
|
|
currentPluginConfigState.schema = null;
|
|
|
|
// Hide validation errors
|
|
displayValidationErrors([]);
|
|
|
|
console.log('Modal closed');
|
|
}
|
|
|
|
// Generic Plugin Action Handler
|
|
window.executePluginAction = function(actionId, actionIndex, pluginIdParam = null) {
|
|
// Get plugin ID from parameter, currentPluginConfig, or try to find from context
|
|
let pluginId = pluginIdParam || currentPluginConfig?.pluginId;
|
|
|
|
// If still no pluginId, try to find it from the button's context or Alpine.js
|
|
if (!pluginId) {
|
|
// Try to get from Alpine.js context if we're in a plugin tab
|
|
if (window.Alpine && document.querySelector('[x-data*="plugin"]')) {
|
|
const pluginTab = document.querySelector(`[x-show*="activeTab === plugin.id"]`);
|
|
if (pluginTab) {
|
|
const pluginData = Alpine.$data(pluginTab.closest('[x-data]'));
|
|
if (pluginData && pluginData.plugin) {
|
|
pluginId = pluginData.plugin.id;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!pluginId) {
|
|
console.error('No plugin ID available. actionId:', actionId, 'actionIndex:', actionIndex);
|
|
if (typeof showNotification === 'function') {
|
|
showNotification('Unable to determine plugin ID. Please refresh the page.', 'error');
|
|
}
|
|
return;
|
|
}
|
|
|
|
console.log('[DEBUG] executePluginAction - pluginId:', pluginId, 'actionId:', actionId, 'actionIndex:', actionIndex);
|
|
|
|
const actionIdFull = `action-${actionId}-${actionIndex}`;
|
|
const statusId = `action-status-${actionId}-${actionIndex}`;
|
|
const btn = document.getElementById(actionIdFull);
|
|
const statusDiv = document.getElementById(statusId);
|
|
|
|
if (!btn || !statusDiv) {
|
|
console.error(`Action elements not found: ${actionIdFull}`);
|
|
return;
|
|
}
|
|
|
|
// Get action definition - try currentPluginConfig first, then fetch from API
|
|
let action = currentPluginConfig?.webUiActions?.[actionIndex];
|
|
|
|
if (!action) {
|
|
// Try to get from installed plugins
|
|
if (window.installedPlugins) {
|
|
const plugin = window.installedPlugins.find(p => p.id === pluginId);
|
|
if (plugin && plugin.web_ui_actions) {
|
|
action = plugin.web_ui_actions[actionIndex];
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!action) {
|
|
console.error(`Action not found: ${actionId} for plugin ${pluginId}`);
|
|
console.log('[DEBUG] currentPluginConfig:', currentPluginConfig);
|
|
console.log('[DEBUG] installedPlugins:', window.installedPlugins);
|
|
if (typeof showNotification === 'function') {
|
|
showNotification(`Action ${actionId} not found. Please refresh the page.`, 'error');
|
|
}
|
|
return;
|
|
}
|
|
|
|
console.log('[DEBUG] Found action:', action);
|
|
|
|
// Check if we're in step 2 (completing OAuth flow)
|
|
if (btn.dataset.step === '2') {
|
|
const redirectUrl = prompt(action.step2_prompt || 'Please paste the full redirect URL:');
|
|
if (!redirectUrl || !redirectUrl.trim()) {
|
|
return;
|
|
}
|
|
|
|
// Complete authentication
|
|
btn.disabled = true;
|
|
const originalText = btn.innerHTML;
|
|
btn.innerHTML = '<i class="fas fa-spinner fa-spin mr-2"></i>Completing...';
|
|
statusDiv.classList.remove('hidden');
|
|
statusDiv.innerHTML = '<div class="text-blue-600"><i class="fas fa-spinner fa-spin mr-2"></i>Completing authentication...</div>';
|
|
|
|
fetch('/api/v3/plugins/action', {
|
|
method: 'POST',
|
|
headers: {'Content-Type': 'application/json'},
|
|
body: JSON.stringify({
|
|
plugin_id: pluginId,
|
|
action_id: actionId,
|
|
params: {step: '2', redirect_url: redirectUrl.trim()}
|
|
})
|
|
})
|
|
.then(response => response.json())
|
|
.then(data => {
|
|
if (data.status === 'success') {
|
|
statusDiv.innerHTML = `<div class="text-green-600"><i class="fas fa-check-circle mr-2"></i>${data.message}</div>`;
|
|
btn.innerHTML = originalText;
|
|
btn.disabled = false;
|
|
delete btn.dataset.step;
|
|
if (typeof showNotification === 'function') {
|
|
showNotification(data.message || 'Action completed successfully!', 'success');
|
|
}
|
|
} else {
|
|
statusDiv.innerHTML = `<div class="text-red-600"><i class="fas fa-exclamation-circle mr-2"></i>${data.message}</div>`;
|
|
if (data.output) {
|
|
statusDiv.innerHTML += `<pre class="mt-2 text-xs bg-red-50 p-2 rounded overflow-auto max-h-32">${data.output}</pre>`;
|
|
}
|
|
btn.innerHTML = originalText;
|
|
btn.disabled = false;
|
|
delete btn.dataset.step;
|
|
}
|
|
})
|
|
.catch(error => {
|
|
statusDiv.innerHTML = `<div class="text-red-600"><i class="fas fa-exclamation-circle mr-2"></i>Error: ${error.message}</div>`;
|
|
btn.innerHTML = originalText;
|
|
btn.disabled = false;
|
|
delete btn.dataset.step;
|
|
});
|
|
return;
|
|
}
|
|
|
|
// Step 1: Execute action
|
|
btn.disabled = true;
|
|
const originalText = btn.innerHTML;
|
|
btn.innerHTML = '<i class="fas fa-spinner fa-spin mr-2"></i>Executing...';
|
|
statusDiv.classList.remove('hidden');
|
|
statusDiv.innerHTML = '<div class="text-blue-600"><i class="fas fa-spinner fa-spin mr-2"></i>Executing action...</div>';
|
|
|
|
fetch('/api/v3/plugins/action', {
|
|
method: 'POST',
|
|
headers: {'Content-Type': 'application/json'},
|
|
body: JSON.stringify({
|
|
plugin_id: pluginId,
|
|
action_id: actionId,
|
|
params: {}
|
|
})
|
|
})
|
|
.then(response => response.json())
|
|
.then(data => {
|
|
if (data.status === 'success') {
|
|
if (data.requires_step2 && data.auth_url) {
|
|
// OAuth flow - show auth URL
|
|
statusDiv.innerHTML = `
|
|
<div class="bg-blue-50 border border-blue-200 rounded p-3">
|
|
<div class="text-blue-900 font-medium mb-2">
|
|
<i class="fas fa-link mr-2"></i>${data.message || 'Authorization URL Generated'}
|
|
</div>
|
|
<div class="mb-3">
|
|
<p class="text-sm text-blue-700 mb-2">1. Click the link below to authorize:</p>
|
|
<a href="${data.auth_url}" target="_blank" class="text-blue-600 hover:text-blue-800 underline break-all">
|
|
${data.auth_url}
|
|
</a>
|
|
</div>
|
|
<div class="mb-2">
|
|
<p class="text-sm text-blue-700 mb-2">2. After authorization, copy the FULL redirect URL from your browser.</p>
|
|
<p class="text-sm text-blue-600">3. Click the button again and paste the redirect URL when prompted.</p>
|
|
</div>
|
|
</div>
|
|
`;
|
|
btn.innerHTML = action.step2_button_text || 'Complete Authentication';
|
|
btn.dataset.step = '2';
|
|
btn.disabled = false;
|
|
if (typeof showNotification === 'function') {
|
|
showNotification(data.message || 'Authorization URL generated. Please authorize and paste the redirect URL.', 'info');
|
|
}
|
|
} else {
|
|
// Simple success
|
|
statusDiv.innerHTML = `
|
|
<div class="bg-green-50 border border-green-200 rounded p-3">
|
|
<div class="text-green-900 font-medium mb-2">
|
|
<i class="fas fa-check-circle mr-2"></i>${data.message || 'Action completed successfully'}
|
|
</div>
|
|
${data.output ? `<pre class="mt-2 text-xs bg-green-50 p-2 rounded overflow-auto max-h-32">${data.output}</pre>` : ''}
|
|
</div>
|
|
`;
|
|
btn.innerHTML = originalText;
|
|
btn.disabled = false;
|
|
if (typeof showNotification === 'function') {
|
|
showNotification(data.message || 'Action completed successfully!', 'success');
|
|
}
|
|
}
|
|
} else {
|
|
statusDiv.innerHTML = `
|
|
<div class="bg-red-50 border border-red-200 rounded p-3">
|
|
<div class="text-red-900 font-medium mb-2">
|
|
<i class="fas fa-exclamation-circle mr-2"></i>${data.message || 'Action failed'}
|
|
</div>
|
|
${data.output ? `<pre class="mt-2 text-xs bg-red-50 p-2 rounded overflow-auto max-h-32">${data.output}</pre>` : ''}
|
|
</div>
|
|
`;
|
|
btn.innerHTML = originalText;
|
|
btn.disabled = false;
|
|
}
|
|
})
|
|
.catch(error => {
|
|
statusDiv.innerHTML = `<div class="text-red-600"><i class="fas fa-exclamation-circle mr-2"></i>Error: ${error.message}</div>`;
|
|
btn.innerHTML = originalText;
|
|
btn.disabled = false;
|
|
});
|
|
}
|
|
|
|
// togglePlugin is already defined at the top of the script - no need to redefine
|
|
|
|
// Only override updatePlugin if it doesn't already have improved error handling
|
|
if (!window.updatePlugin || window.updatePlugin.toString().includes('[UPDATE]')) {
|
|
window.updatePlugin = function(pluginId) {
|
|
// Validate pluginId
|
|
if (!pluginId || typeof pluginId !== 'string') {
|
|
console.error('[UPDATE] Invalid pluginId:', pluginId);
|
|
if (typeof showNotification === 'function') {
|
|
showNotification('Invalid plugin ID', 'error');
|
|
}
|
|
return Promise.reject(new Error('Invalid plugin ID'));
|
|
}
|
|
|
|
showNotification(`Updating ${pluginId}...`, 'info');
|
|
|
|
// Prepare request body
|
|
const requestBody = { plugin_id: pluginId };
|
|
const requestBodyJson = JSON.stringify(requestBody);
|
|
|
|
console.log('[UPDATE] Sending request:', { url: '/api/v3/plugins/update', body: requestBodyJson });
|
|
|
|
return fetch('/api/v3/plugins/update', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Accept': 'application/json'
|
|
},
|
|
body: requestBodyJson
|
|
})
|
|
.then(async response => {
|
|
// Check if response is OK before parsing
|
|
if (!response.ok) {
|
|
// Try to parse error response
|
|
let errorData;
|
|
try {
|
|
const text = await response.text();
|
|
console.error('[UPDATE] Error response:', { status: response.status, statusText: response.statusText, body: text });
|
|
errorData = JSON.parse(text);
|
|
} catch (e) {
|
|
errorData = { message: `Server error: ${response.status} ${response.statusText}` };
|
|
}
|
|
|
|
if (typeof showNotification === 'function') {
|
|
showNotification(errorData.message || `Update failed: ${response.status}`, 'error');
|
|
}
|
|
throw new Error(errorData.message || `Update failed: ${response.status}`);
|
|
}
|
|
|
|
// Parse successful response
|
|
return response.json();
|
|
})
|
|
.then(data => {
|
|
showNotification(data.message || 'Update initiated', data.status || 'info');
|
|
if (data.status === 'success') {
|
|
// Refresh the list
|
|
if (typeof loadInstalledPlugins === 'function') {
|
|
loadInstalledPlugins();
|
|
} else if (typeof window.pluginManager?.loadInstalledPlugins === 'function') {
|
|
window.pluginManager.loadInstalledPlugins();
|
|
}
|
|
}
|
|
return data;
|
|
})
|
|
.catch(error => {
|
|
console.error('[UPDATE] Error updating plugin:', error);
|
|
if (typeof showNotification === 'function') {
|
|
showNotification('Error updating plugin: ' + error.message, 'error');
|
|
}
|
|
throw error;
|
|
});
|
|
};
|
|
}
|
|
|
|
window.uninstallPlugin = function(pluginId) {
|
|
const plugin = (window.installedPlugins || installedPlugins || []).find(p => p.id === pluginId);
|
|
const pluginName = plugin ? (plugin.name || pluginId) : pluginId;
|
|
|
|
if (!confirm(`Are you sure you want to uninstall ${pluginName}?`)) {
|
|
return;
|
|
}
|
|
|
|
showNotification(`Uninstalling ${pluginName}...`, 'info');
|
|
|
|
fetch('/api/v3/plugins/uninstall', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ plugin_id: pluginId })
|
|
})
|
|
.then(response => response.json())
|
|
.then(data => {
|
|
console.log('Uninstall response:', data);
|
|
|
|
// Check if operation was queued
|
|
if (data.status === 'success' && data.data && data.data.operation_id) {
|
|
// Operation was queued, poll for completion
|
|
const operationId = data.data.operation_id;
|
|
showNotification(`Uninstall queued for ${pluginName}...`, 'info');
|
|
pollOperationStatus(operationId, pluginId, pluginName);
|
|
} else if (data.status === 'success') {
|
|
// Direct uninstall completed immediately
|
|
handleUninstallSuccess(pluginId);
|
|
} else {
|
|
// Error response
|
|
showNotification(data.message || 'Failed to uninstall plugin', data.status || 'error');
|
|
}
|
|
})
|
|
.catch(error => {
|
|
console.error('Error uninstalling plugin:', error);
|
|
showNotification('Error uninstalling plugin: ' + error.message, 'error');
|
|
});
|
|
}
|
|
|
|
function pollOperationStatus(operationId, pluginId, pluginName, maxAttempts = 60, attempt = 0) {
|
|
if (attempt >= maxAttempts) {
|
|
showNotification(`Uninstall operation timed out for ${pluginName}`, 'error');
|
|
// Refresh plugin list to see actual state
|
|
setTimeout(() => {
|
|
loadInstalledPlugins();
|
|
}, 1000);
|
|
return;
|
|
}
|
|
|
|
fetch(`/api/v3/plugins/operation/${operationId}`)
|
|
.then(response => response.json())
|
|
.then(data => {
|
|
if (data.status === 'success' && data.data) {
|
|
const operation = data.data;
|
|
const status = operation.status;
|
|
|
|
if (status === 'completed') {
|
|
// Operation completed successfully
|
|
handleUninstallSuccess(pluginId);
|
|
} else if (status === 'failed') {
|
|
// Operation failed
|
|
const errorMsg = operation.error || operation.message || `Failed to uninstall ${pluginName}`;
|
|
showNotification(errorMsg, 'error');
|
|
// Refresh plugin list to see actual state
|
|
setTimeout(() => {
|
|
loadInstalledPlugins();
|
|
}, 1000);
|
|
} else if (status === 'pending' || status === 'in_progress') {
|
|
// Still in progress, poll again
|
|
setTimeout(() => {
|
|
pollOperationStatus(operationId, pluginId, pluginName, maxAttempts, attempt + 1);
|
|
}, 1000); // Poll every second
|
|
} else {
|
|
// Unknown status, poll again
|
|
setTimeout(() => {
|
|
pollOperationStatus(operationId, pluginId, pluginName, maxAttempts, attempt + 1);
|
|
}, 1000);
|
|
}
|
|
} else {
|
|
// Error getting operation status, try again
|
|
setTimeout(() => {
|
|
pollOperationStatus(operationId, pluginId, pluginName, maxAttempts, attempt + 1);
|
|
}, 1000);
|
|
}
|
|
})
|
|
.catch(error => {
|
|
console.error('Error polling operation status:', error);
|
|
// On error, refresh plugin list to see actual state
|
|
setTimeout(() => {
|
|
loadInstalledPlugins();
|
|
}, 1000);
|
|
});
|
|
}
|
|
|
|
function handleUninstallSuccess(pluginId) {
|
|
// Remove from local array immediately for better UX
|
|
const currentPlugins = window.installedPlugins || installedPlugins || [];
|
|
const updatedPlugins = currentPlugins.filter(p => p.id !== pluginId);
|
|
// Only update if list actually changed (setter will check, but we know it changed here)
|
|
window.installedPlugins = updatedPlugins;
|
|
if (typeof installedPlugins !== 'undefined') {
|
|
installedPlugins = updatedPlugins;
|
|
}
|
|
renderInstalledPlugins(updatedPlugins);
|
|
showNotification(`Plugin uninstalled successfully`, 'success');
|
|
|
|
// Also refresh from server to ensure consistency
|
|
setTimeout(() => {
|
|
loadInstalledPlugins();
|
|
}, 1000);
|
|
}
|
|
|
|
function refreshPlugins() {
|
|
// Clear cache to force fresh data
|
|
pluginStoreCache = null;
|
|
cacheTimestamp = null;
|
|
|
|
loadInstalledPlugins();
|
|
// Fetch latest metadata from GitHub when refreshing
|
|
searchPluginStore(true);
|
|
showNotification('Plugins refreshed with latest metadata from GitHub', 'success');
|
|
}
|
|
|
|
function restartDisplay() {
|
|
showNotification('Restarting display service...', 'info');
|
|
|
|
fetch('/api/v3/system/action', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ action: 'restart_display_service' })
|
|
})
|
|
.then(response => response.json())
|
|
.then(data => {
|
|
showNotification(data.message, data.status);
|
|
})
|
|
.catch(error => {
|
|
showNotification('Error restarting display: ' + error.message, 'error');
|
|
});
|
|
}
|
|
|
|
function searchPluginStore(fetchCommitInfo = true) {
|
|
pluginLog('[STORE] Searching plugin store...', { fetchCommitInfo });
|
|
|
|
// Safely get search values (elements may not exist yet)
|
|
const searchInput = document.getElementById('plugin-search');
|
|
const categorySelect = document.getElementById('plugin-category');
|
|
const query = searchInput ? searchInput.value : '';
|
|
const category = categorySelect ? categorySelect.value : '';
|
|
|
|
// For filtered searches (user typing), we can use cache to avoid excessive API calls
|
|
// For initial load or refresh, always fetch fresh metadata
|
|
const isFilteredSearch = query || category;
|
|
const now = Date.now();
|
|
const isCacheValid = pluginStoreCache && cacheTimestamp && (now - cacheTimestamp < CACHE_DURATION);
|
|
|
|
// Only use cache for filtered searches that don't explicitly request fresh metadata
|
|
if (isFilteredSearch && isCacheValid && !fetchCommitInfo) {
|
|
console.log('Using cached plugin store data for filtered search');
|
|
// Ensure plugin store grid exists before rendering
|
|
const storeGrid = document.getElementById('plugin-store-grid');
|
|
if (!storeGrid) {
|
|
console.error('plugin-store-grid element not found, cannot render cached plugins');
|
|
// Don't return, let it fetch fresh data
|
|
} else {
|
|
renderPluginStore(pluginStoreCache);
|
|
try {
|
|
const countEl = document.getElementById('store-count');
|
|
if (countEl) {
|
|
countEl.innerHTML = `${pluginStoreCache.length} available`;
|
|
}
|
|
} catch (e) {
|
|
console.warn('Could not update store count:', e);
|
|
}
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Show loading state - safely check element exists
|
|
try {
|
|
const countEl = document.getElementById('store-count');
|
|
if (countEl) {
|
|
countEl.innerHTML = '<i class="fas fa-spinner fa-spin mr-1"></i>Loading...';
|
|
}
|
|
} catch (e) {
|
|
console.warn('Could not update store count:', e);
|
|
}
|
|
showStoreLoading(true);
|
|
|
|
let url = '/api/v3/plugins/store/list';
|
|
const params = new URLSearchParams();
|
|
if (query) params.append('query', query);
|
|
if (category) params.append('category', category);
|
|
// Always fetch fresh commit metadata unless explicitly disabled (for performance on repeated filtered searches)
|
|
if (!fetchCommitInfo) {
|
|
params.append('fetch_commit_info', 'false');
|
|
}
|
|
// Note: fetch_commit_info defaults to true on the server side to keep metadata fresh
|
|
|
|
if (params.toString()) {
|
|
url += '?' + params.toString();
|
|
}
|
|
|
|
console.log('Store URL:', url);
|
|
|
|
fetch(url)
|
|
.then(response => {
|
|
console.log('Store response:', response.status);
|
|
return response.json();
|
|
})
|
|
.then(data => {
|
|
console.log('Store data:', data);
|
|
showStoreLoading(false);
|
|
|
|
if (data.status === 'success') {
|
|
const plugins = data.data.plugins || [];
|
|
console.log('Store plugins count:', plugins.length);
|
|
|
|
// Cache the results if no filters
|
|
if (!query && !category) {
|
|
pluginStoreCache = plugins;
|
|
cacheTimestamp = Date.now();
|
|
console.log('Cached plugin store data');
|
|
}
|
|
|
|
// Ensure plugin store grid exists before rendering
|
|
const storeGrid = document.getElementById('plugin-store-grid');
|
|
if (!storeGrid) {
|
|
// Defer rendering until plugin tab loads
|
|
pluginLog('[STORE] plugin-store-grid not ready, deferring render');
|
|
window.__pendingStorePlugins = plugins;
|
|
return;
|
|
}
|
|
|
|
renderPluginStore(plugins);
|
|
|
|
// Update count - safely check element exists
|
|
try {
|
|
const countEl = document.getElementById('store-count');
|
|
if (countEl) {
|
|
countEl.innerHTML = `${plugins.length} available`;
|
|
}
|
|
} catch (e) {
|
|
console.warn('Could not update store count:', e);
|
|
}
|
|
} else {
|
|
showError('Failed to search plugin store: ' + data.message);
|
|
try {
|
|
const countEl = document.getElementById('store-count');
|
|
if (countEl) {
|
|
countEl.innerHTML = 'Error loading';
|
|
}
|
|
} catch (e) {
|
|
console.warn('Could not update store count:', e);
|
|
}
|
|
}
|
|
})
|
|
.catch(error => {
|
|
console.error('Error searching plugin store:', error);
|
|
showStoreLoading(false);
|
|
let errorMsg = 'Error searching plugin store: ' + error.message;
|
|
if (error.message && error.message.includes('Failed to Fetch')) {
|
|
errorMsg += ' - Please try refreshing your browser.';
|
|
}
|
|
showError(errorMsg);
|
|
try {
|
|
const countEl = document.getElementById('store-count');
|
|
if (countEl) {
|
|
countEl.innerHTML = 'Error loading';
|
|
}
|
|
} catch (e) {
|
|
console.warn('Could not update store count:', e);
|
|
}
|
|
});
|
|
}
|
|
|
|
function showStoreLoading(show) {
|
|
const loading = document.querySelector('.store-loading');
|
|
if (loading) {
|
|
loading.style.display = show ? 'block' : 'none';
|
|
}
|
|
}
|
|
|
|
// Expose searchPluginStore on window.pluginManager for Alpine.js integration
|
|
window.searchPluginStore = searchPluginStore;
|
|
window.pluginManager.searchPluginStore = searchPluginStore;
|
|
|
|
function renderPluginStore(plugins) {
|
|
const container = document.getElementById('plugin-store-grid');
|
|
if (!container) {
|
|
pluginLog('[RENDER] plugin-store-grid not yet available, deferring render');
|
|
window.__pendingStorePlugins = plugins;
|
|
return;
|
|
}
|
|
|
|
if (plugins.length === 0) {
|
|
container.innerHTML = `
|
|
<div class="col-span-full empty-state">
|
|
<div class="empty-state-icon">
|
|
<i class="fas fa-store"></i>
|
|
</div>
|
|
<p class="text-lg font-medium text-gray-700 mb-1">No plugins found</p>
|
|
<p class="text-sm text-gray-500">Try adjusting your search criteria</p>
|
|
</div>
|
|
`;
|
|
return;
|
|
}
|
|
|
|
// Helper function to escape for JavaScript strings
|
|
const escapeJs = (text) => {
|
|
return JSON.stringify(text || '');
|
|
};
|
|
|
|
container.innerHTML = plugins.map(plugin => `
|
|
<div class="plugin-card">
|
|
<div class="flex items-start justify-between mb-4">
|
|
<div class="flex-1 min-w-0">
|
|
<div class="flex items-center flex-wrap gap-2 mb-2">
|
|
<h4 class="font-semibold text-gray-900 text-base">${escapeHtml(plugin.name || plugin.id)}</h4>
|
|
${plugin.verified ? '<span class="badge badge-success"><i class="fas fa-check-circle mr-1"></i>Verified</span>' : ''}
|
|
${isNewPlugin(plugin.last_updated) ? '<span class="badge badge-info"><i class="fas fa-sparkles mr-1"></i>New</span>' : ''}
|
|
${plugin._source === 'custom_repository' ? `<span class="badge badge-accent" title="From: ${escapeHtml(plugin._repository_name || plugin._repository_url || 'Custom Repository')}"><i class="fas fa-bookmark mr-1"></i>Custom</span>` : ''}
|
|
</div>
|
|
<div class="text-sm text-gray-600 space-y-1.5 mb-3">
|
|
<p class="flex items-center"><i class="fas fa-user mr-2 text-gray-400 w-4"></i>${escapeHtml(plugin.author || 'Unknown')}</p>
|
|
<p class="flex items-center"><i class="fas fa-code-branch mr-2 text-gray-400 w-4"></i>${formatCommit(plugin.last_commit, plugin.branch)}</p>
|
|
<p class="flex items-center"><i class="fas fa-calendar mr-2 text-gray-400 w-4"></i>${formatDate(plugin.last_updated)}</p>
|
|
<p class="flex items-center"><i class="fas fa-tag mr-2 text-gray-400 w-4"></i>${escapeHtml(plugin.category || 'General')}</p>
|
|
<p class="flex items-center"><i class="fas fa-star mr-2 text-gray-400 w-4"></i>${plugin.stars || 0} stars</p>
|
|
</div>
|
|
<p class="text-sm text-gray-700 leading-relaxed">${escapeHtml(plugin.description || 'No description available')}</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Plugin Tags -->
|
|
${plugin.tags && plugin.tags.length > 0 ? `
|
|
<div class="flex flex-wrap gap-1.5 mb-4">
|
|
${plugin.tags.map(tag => `<span class="badge badge-info">${escapeHtml(tag)}</span>`).join('')}
|
|
</div>
|
|
` : ''}
|
|
|
|
<!-- Store Actions -->
|
|
<div class="mt-4 pt-4 border-t border-gray-200 space-y-2">
|
|
<div class="flex items-center gap-2">
|
|
<label for="branch-input-${plugin.id.replace(/[^a-zA-Z0-9]/g, '-')}" class="text-xs text-gray-600 whitespace-nowrap">
|
|
<i class="fas fa-code-branch mr-1"></i>Branch:
|
|
</label>
|
|
<input type="text" id="branch-input-${plugin.id.replace(/[^a-zA-Z0-9]/g, '-')}"
|
|
placeholder="main (default)"
|
|
class="flex-1 px-2 py-1 text-xs border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500">
|
|
</div>
|
|
<div class="flex gap-2">
|
|
<button onclick='if(window.installPlugin){const branchInput = document.getElementById("branch-input-${plugin.id.replace(/[^a-zA-Z0-9]/g, '-')}"); window.installPlugin(${escapeJs(plugin.id)}, branchInput?.value?.trim() || null)}else{console.error("installPlugin not available")}' class="btn bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-md text-sm flex-1 font-semibold">
|
|
<i class="fas fa-download mr-2"></i>Install
|
|
</button>
|
|
<button onclick='window.open(${escapeJs(plugin.repo || '#')}, "_blank")' class="btn bg-gray-600 hover:bg-gray-700 text-white px-4 py-2 rounded-md text-sm flex-1 font-semibold">
|
|
<i class="fas fa-external-link-alt mr-2"></i>View
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`).join('');
|
|
}
|
|
|
|
// Expose functions to window for onclick handlers
|
|
window.installPlugin = function(pluginId, branch = null) {
|
|
showNotification(`Installing ${pluginId}${branch ? ` (branch: ${branch})` : ''}...`, 'info');
|
|
|
|
const requestBody = { plugin_id: pluginId };
|
|
if (branch) {
|
|
requestBody.branch = branch;
|
|
}
|
|
|
|
fetch('/api/v3/plugins/install', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(requestBody)
|
|
})
|
|
.then(response => response.json())
|
|
.then(data => {
|
|
showNotification(data.message, data.status);
|
|
if (data.status === 'success') {
|
|
// Refresh both installed plugins and store
|
|
loadInstalledPlugins();
|
|
// Delay store refresh slightly to ensure DOM is ready
|
|
setTimeout(() => {
|
|
searchPluginStore();
|
|
}, 100);
|
|
}
|
|
})
|
|
.catch(error => {
|
|
showNotification('Error installing plugin: ' + error.message, 'error');
|
|
});
|
|
}
|
|
|
|
function setupCollapsibleSections() {
|
|
// Toggle Installed Plugins section
|
|
const toggleInstalledBtn = document.getElementById('toggle-installed-plugins');
|
|
const installedContent = document.getElementById('installed-plugins-content');
|
|
const installedIcon = document.getElementById('installed-plugins-icon');
|
|
|
|
if (toggleInstalledBtn && installedContent) {
|
|
toggleInstalledBtn.addEventListener('click', function() {
|
|
const isHidden = installedContent.style.display === 'none' || installedContent.classList.contains('hidden');
|
|
if (isHidden) {
|
|
installedContent.style.display = 'block';
|
|
installedContent.classList.remove('hidden');
|
|
installedIcon.classList.remove('fa-chevron-down');
|
|
installedIcon.classList.add('fa-chevron-up');
|
|
toggleInstalledBtn.querySelector('span').textContent = 'Collapse';
|
|
} else {
|
|
installedContent.style.display = 'none';
|
|
installedContent.classList.add('hidden');
|
|
installedIcon.classList.remove('fa-chevron-up');
|
|
installedIcon.classList.add('fa-chevron-down');
|
|
toggleInstalledBtn.querySelector('span').textContent = 'Expand';
|
|
}
|
|
});
|
|
}
|
|
|
|
// Toggle Plugin Store section
|
|
const toggleStoreBtn = document.getElementById('toggle-plugin-store');
|
|
const storeContent = document.getElementById('plugin-store-content');
|
|
const storeIcon = document.getElementById('plugin-store-icon');
|
|
|
|
if (toggleStoreBtn && storeContent) {
|
|
toggleStoreBtn.addEventListener('click', function() {
|
|
const isHidden = storeContent.style.display === 'none' || storeContent.classList.contains('hidden');
|
|
if (isHidden) {
|
|
storeContent.style.display = 'block';
|
|
storeContent.classList.remove('hidden');
|
|
storeIcon.classList.remove('fa-chevron-down');
|
|
storeIcon.classList.add('fa-chevron-up');
|
|
toggleStoreBtn.querySelector('span').textContent = 'Collapse';
|
|
} else {
|
|
storeContent.style.display = 'none';
|
|
storeContent.classList.add('hidden');
|
|
storeIcon.classList.remove('fa-chevron-up');
|
|
storeIcon.classList.add('fa-chevron-down');
|
|
toggleStoreBtn.querySelector('span').textContent = 'Expand';
|
|
}
|
|
});
|
|
}
|
|
|
|
// Toggle GitHub Token Settings section
|
|
const toggleTokenCollapseBtn = document.getElementById('toggle-github-token-collapse');
|
|
const tokenContent = document.getElementById('github-token-content');
|
|
const tokenIconCollapse = document.getElementById('github-token-icon-collapse');
|
|
|
|
if (toggleTokenCollapseBtn && tokenContent) {
|
|
toggleTokenCollapseBtn.addEventListener('click', function(e) {
|
|
e.stopPropagation(); // Prevent triggering the close button
|
|
const isHidden = tokenContent.style.display === 'none' || tokenContent.classList.contains('hidden');
|
|
if (isHidden) {
|
|
tokenContent.style.display = 'block';
|
|
tokenContent.classList.remove('hidden');
|
|
tokenIconCollapse.classList.remove('fa-chevron-down');
|
|
tokenIconCollapse.classList.add('fa-chevron-up');
|
|
toggleTokenCollapseBtn.querySelector('span').textContent = 'Collapse';
|
|
} else {
|
|
tokenContent.style.display = 'none';
|
|
tokenContent.classList.add('hidden');
|
|
tokenIconCollapse.classList.remove('fa-chevron-up');
|
|
tokenIconCollapse.classList.add('fa-chevron-down');
|
|
toggleTokenCollapseBtn.querySelector('span').textContent = 'Expand';
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
function loadSavedRepositories() {
|
|
fetch('/api/v3/plugins/saved-repositories')
|
|
.then(response => response.json())
|
|
.then(data => {
|
|
if (data.status === 'success') {
|
|
renderSavedRepositories(data.data.repositories || []);
|
|
}
|
|
})
|
|
.catch(error => {
|
|
console.error('Error loading saved repositories:', error);
|
|
});
|
|
}
|
|
|
|
function renderSavedRepositories(repositories) {
|
|
const container = document.getElementById('saved-repositories-list');
|
|
const countEl = document.getElementById('saved-repos-count');
|
|
|
|
if (!container) return;
|
|
|
|
if (countEl) {
|
|
countEl.textContent = `${repositories.length} saved`;
|
|
}
|
|
|
|
if (repositories.length === 0) {
|
|
container.innerHTML = '<p class="text-xs text-gray-500 italic">No saved repositories yet. Save a repository URL to see it here.</p>';
|
|
return;
|
|
}
|
|
|
|
// Helper function to escape for JavaScript strings
|
|
const escapeJs = (text) => {
|
|
return JSON.stringify(text || '');
|
|
};
|
|
|
|
container.innerHTML = repositories.map(repo => {
|
|
const repoUrl = repo.url || '';
|
|
const repoName = repo.name || repoUrl;
|
|
const repoType = repo.type || 'single';
|
|
|
|
return `
|
|
<div class="bg-white border border-gray-200 rounded p-2 flex items-center justify-between">
|
|
<div class="flex-1 min-w-0">
|
|
<div class="flex items-center gap-2">
|
|
<i class="fas ${repoType === 'registry' ? 'fa-folder-open' : 'fa-code-branch'} text-gray-400 text-xs"></i>
|
|
<span class="text-sm font-medium text-gray-900 truncate" title="${repoUrl}">${escapeHtml(repoName)}</span>
|
|
</div>
|
|
<p class="text-xs text-gray-500 truncate" title="${repoUrl}">${escapeHtml(repoUrl)}</p>
|
|
</div>
|
|
<button onclick='if(window.removeSavedRepository){window.removeSavedRepository(${escapeJs(repoUrl)})}else{console.error("removeSavedRepository not available")}' class="ml-2 text-red-600 hover:text-red-800 text-xs px-2 py-1" title="Remove repository">
|
|
<i class="fas fa-trash"></i>
|
|
</button>
|
|
</div>
|
|
`;
|
|
}).join('');
|
|
}
|
|
|
|
window.removeSavedRepository = function(repoUrl) {
|
|
if (!confirm('Remove this saved repository? Its plugins will no longer appear in the store.')) {
|
|
return;
|
|
}
|
|
|
|
fetch('/api/v3/plugins/saved-repositories', {
|
|
method: 'DELETE',
|
|
headers: {
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify({ repo_url: repoUrl })
|
|
})
|
|
.then(response => response.json())
|
|
.then(data => {
|
|
if (data.status === 'success') {
|
|
showSuccess('Repository removed successfully');
|
|
renderSavedRepositories(data.data.repositories || []);
|
|
// Refresh plugin store to remove plugins from deleted repo
|
|
searchPluginStore();
|
|
} else {
|
|
showError(data.message || 'Failed to remove repository');
|
|
}
|
|
})
|
|
.catch(error => {
|
|
showError('Error removing repository: ' + error.message);
|
|
});
|
|
}
|
|
|
|
function setupGitHubInstallHandlers() {
|
|
// Toggle GitHub install section visibility
|
|
const toggleBtn = document.getElementById('toggle-github-install');
|
|
const installSection = document.getElementById('github-install-section');
|
|
const icon = document.getElementById('github-install-icon');
|
|
|
|
if (toggleBtn && installSection) {
|
|
toggleBtn.addEventListener('click', function() {
|
|
const isHidden = installSection.classList.contains('hidden');
|
|
if (isHidden) {
|
|
installSection.classList.remove('hidden');
|
|
icon.classList.remove('fa-chevron-down');
|
|
icon.classList.add('fa-chevron-up');
|
|
toggleBtn.querySelector('span').textContent = 'Hide';
|
|
} else {
|
|
installSection.classList.add('hidden');
|
|
icon.classList.remove('fa-chevron-up');
|
|
icon.classList.add('fa-chevron-down');
|
|
toggleBtn.querySelector('span').textContent = 'Show';
|
|
}
|
|
});
|
|
}
|
|
|
|
// Install single plugin from URL
|
|
const installBtn = document.getElementById('install-plugin-from-url');
|
|
const pluginUrlInput = document.getElementById('github-plugin-url');
|
|
const pluginStatusDiv = document.getElementById('github-plugin-status');
|
|
|
|
if (installBtn && pluginUrlInput) {
|
|
installBtn.addEventListener('click', function() {
|
|
const repoUrl = pluginUrlInput.value.trim();
|
|
if (!repoUrl) {
|
|
pluginStatusDiv.innerHTML = '<span class="text-red-600"><i class="fas fa-exclamation-circle mr-1"></i>Please enter a GitHub URL</span>';
|
|
return;
|
|
}
|
|
|
|
if (!repoUrl.includes('github.com')) {
|
|
pluginStatusDiv.innerHTML = '<span class="text-red-600"><i class="fas fa-exclamation-circle mr-1"></i>Please enter a valid GitHub URL</span>';
|
|
return;
|
|
}
|
|
|
|
installBtn.disabled = true;
|
|
installBtn.innerHTML = '<i class="fas fa-spinner fa-spin mr-2"></i>Installing...';
|
|
pluginStatusDiv.innerHTML = '<span class="text-blue-600"><i class="fas fa-spinner fa-spin mr-1"></i>Installing plugin...</span>';
|
|
|
|
const branch = document.getElementById('plugin-branch-input')?.value?.trim() || null;
|
|
const requestBody = { repo_url: repoUrl };
|
|
if (branch) {
|
|
requestBody.branch = branch;
|
|
}
|
|
|
|
fetch('/api/v3/plugins/install-from-url', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify(requestBody)
|
|
})
|
|
.then(response => response.json())
|
|
.then(data => {
|
|
if (data.status === 'success') {
|
|
pluginStatusDiv.innerHTML = `<span class="text-green-600"><i class="fas fa-check-circle mr-1"></i>Successfully installed: ${data.plugin_id}</span>`;
|
|
pluginUrlInput.value = '';
|
|
|
|
// Refresh installed plugins list
|
|
setTimeout(() => {
|
|
loadInstalledPlugins();
|
|
}, 1000);
|
|
} else {
|
|
pluginStatusDiv.innerHTML = `<span class="text-red-600"><i class="fas fa-times-circle mr-1"></i>${data.message || 'Installation failed'}</span>`;
|
|
}
|
|
})
|
|
.catch(error => {
|
|
pluginStatusDiv.innerHTML = `<span class="text-red-600"><i class="fas fa-times-circle mr-1"></i>Error: ${error.message}</span>`;
|
|
})
|
|
.finally(() => {
|
|
installBtn.disabled = false;
|
|
installBtn.innerHTML = '<i class="fas fa-download mr-2"></i>Install';
|
|
});
|
|
});
|
|
|
|
// Allow Enter key to trigger install
|
|
pluginUrlInput.addEventListener('keypress', function(e) {
|
|
if (e.key === 'Enter') {
|
|
installBtn.click();
|
|
}
|
|
});
|
|
}
|
|
|
|
// Load registry from URL
|
|
const loadRegistryBtn = document.getElementById('load-registry-from-url');
|
|
const registryUrlInput = document.getElementById('github-registry-url');
|
|
const registryStatusDiv = document.getElementById('registry-status');
|
|
const customRegistryPlugins = document.getElementById('custom-registry-plugins');
|
|
const customRegistryGrid = document.getElementById('custom-registry-grid');
|
|
|
|
if (loadRegistryBtn && registryUrlInput) {
|
|
loadRegistryBtn.addEventListener('click', function() {
|
|
const repoUrl = registryUrlInput.value.trim();
|
|
if (!repoUrl) {
|
|
registryStatusDiv.innerHTML = '<span class="text-red-600"><i class="fas fa-exclamation-circle mr-1"></i>Please enter a GitHub URL</span>';
|
|
return;
|
|
}
|
|
|
|
if (!repoUrl.includes('github.com')) {
|
|
registryStatusDiv.innerHTML = '<span class="text-red-600"><i class="fas fa-exclamation-circle mr-1"></i>Please enter a valid GitHub URL</span>';
|
|
return;
|
|
}
|
|
|
|
loadRegistryBtn.disabled = true;
|
|
loadRegistryBtn.innerHTML = '<i class="fas fa-spinner fa-spin mr-2"></i>Loading...';
|
|
registryStatusDiv.innerHTML = '<span class="text-blue-600"><i class="fas fa-spinner fa-spin mr-1"></i>Loading registry...</span>';
|
|
customRegistryPlugins.classList.add('hidden');
|
|
|
|
fetch('/api/v3/plugins/registry-from-url', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify({ repo_url: repoUrl })
|
|
})
|
|
.then(response => response.json())
|
|
.then(data => {
|
|
if (data.status === 'success' && data.plugins && data.plugins.length > 0) {
|
|
registryStatusDiv.innerHTML = `<span class="text-green-600"><i class="fas fa-check-circle mr-1"></i>Found ${data.plugins.length} plugins</span>`;
|
|
renderCustomRegistryPlugins(data.plugins, repoUrl);
|
|
customRegistryPlugins.classList.remove('hidden');
|
|
} else {
|
|
registryStatusDiv.innerHTML = '<span class="text-red-600"><i class="fas fa-times-circle mr-1"></i>No valid registry found or registry is empty</span>';
|
|
customRegistryPlugins.classList.add('hidden');
|
|
}
|
|
})
|
|
.catch(error => {
|
|
registryStatusDiv.innerHTML = `<span class="text-red-600"><i class="fas fa-times-circle mr-1"></i>Error: ${error.message}</span>`;
|
|
customRegistryPlugins.classList.add('hidden');
|
|
})
|
|
.finally(() => {
|
|
loadRegistryBtn.disabled = false;
|
|
loadRegistryBtn.innerHTML = '<i class="fas fa-search mr-2"></i>Load Registry';
|
|
});
|
|
});
|
|
|
|
// Allow Enter key to trigger load
|
|
registryUrlInput.addEventListener('keypress', function(e) {
|
|
if (e.key === 'Enter') {
|
|
loadRegistryBtn.click();
|
|
}
|
|
});
|
|
}
|
|
|
|
// Save registry URL button
|
|
const saveRegistryBtn = document.getElementById('save-registry-url');
|
|
if (saveRegistryBtn && registryUrlInput) {
|
|
saveRegistryBtn.addEventListener('click', function() {
|
|
const repoUrl = registryUrlInput.value.trim();
|
|
if (!repoUrl) {
|
|
showError('Please enter a repository URL first');
|
|
return;
|
|
}
|
|
|
|
if (!repoUrl.includes('github.com')) {
|
|
showError('Please enter a valid GitHub URL');
|
|
return;
|
|
}
|
|
|
|
saveRegistryBtn.disabled = true;
|
|
saveRegistryBtn.innerHTML = '<i class="fas fa-spinner fa-spin mr-2"></i>Saving...';
|
|
|
|
fetch('/api/v3/plugins/saved-repositories', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify({ repo_url: repoUrl })
|
|
})
|
|
.then(response => response.json())
|
|
.then(data => {
|
|
if (data.status === 'success') {
|
|
showSuccess('Repository saved successfully! Its plugins will appear in the Plugin Store.');
|
|
renderSavedRepositories(data.data.repositories || []);
|
|
// Refresh plugin store to include new repo
|
|
searchPluginStore();
|
|
} else {
|
|
showError(data.message || 'Failed to save repository');
|
|
}
|
|
})
|
|
.catch(error => {
|
|
showError('Error saving repository: ' + error.message);
|
|
})
|
|
.finally(() => {
|
|
saveRegistryBtn.disabled = false;
|
|
saveRegistryBtn.innerHTML = '<i class="fas fa-bookmark mr-2"></i>Save Repository';
|
|
});
|
|
});
|
|
}
|
|
|
|
// Refresh saved repos button
|
|
const refreshSavedReposBtn = document.getElementById('refresh-saved-repos');
|
|
if (refreshSavedReposBtn) {
|
|
refreshSavedReposBtn.addEventListener('click', function() {
|
|
loadSavedRepositories();
|
|
searchPluginStore(); // Also refresh plugin store
|
|
showSuccess('Repositories refreshed');
|
|
});
|
|
}
|
|
}
|
|
|
|
function renderCustomRegistryPlugins(plugins, registryUrl) {
|
|
const container = document.getElementById('custom-registry-grid');
|
|
if (!container) return;
|
|
|
|
if (plugins.length === 0) {
|
|
container.innerHTML = '<p class="text-sm text-gray-500 col-span-full">No plugins found in this registry</p>';
|
|
return;
|
|
}
|
|
|
|
// Escape HTML helper
|
|
const escapeHtml = (text) => {
|
|
if (!text) return '';
|
|
const div = document.createElement('div');
|
|
div.textContent = text;
|
|
return div.innerHTML;
|
|
};
|
|
|
|
// Helper function to escape for JavaScript strings
|
|
const escapeJs = (text) => {
|
|
return JSON.stringify(text || '');
|
|
};
|
|
|
|
container.innerHTML = plugins.map(plugin => {
|
|
const isInstalled = installedPlugins.some(p => p.id === plugin.id);
|
|
const pluginIdJs = escapeJs(plugin.id);
|
|
const escapedUrlJs = escapeJs(registryUrl);
|
|
const pluginPathJs = escapeJs(plugin.plugin_path || '');
|
|
const branchInputId = `branch-input-custom-${plugin.id.replace(/[^a-zA-Z0-9]/g, '-')}`;
|
|
|
|
const installBtn = isInstalled
|
|
? '<button class="px-3 py-1 text-xs bg-gray-400 text-white rounded cursor-not-allowed" disabled><i class="fas fa-check mr-1"></i>Installed</button>'
|
|
: `<button onclick='if(window.installFromCustomRegistry){const branchInput = document.getElementById("${branchInputId}"); window.installFromCustomRegistry(${pluginIdJs}, ${escapedUrlJs}, ${pluginPathJs}, branchInput?.value?.trim() || null)}else{console.error("installFromCustomRegistry not available")}' class="px-3 py-1 text-xs bg-blue-600 hover:bg-blue-700 text-white rounded"><i class="fas fa-download mr-1"></i>Install</button>`;
|
|
|
|
return `
|
|
<div class="bg-white border border-gray-200 rounded-lg p-3">
|
|
<div class="flex items-start justify-between mb-2">
|
|
<div class="flex-1">
|
|
<h5 class="font-semibold text-sm text-gray-900">${escapeHtml(plugin.name || plugin.id)}</h5>
|
|
<p class="text-xs text-gray-600 mt-1 line-clamp-2">${escapeHtml(plugin.description || 'No description')}</p>
|
|
</div>
|
|
</div>
|
|
<div class="space-y-2 mt-2 pt-2 border-t border-gray-100">
|
|
<div class="flex items-center gap-2">
|
|
<label for="${branchInputId}" class="text-xs text-gray-600 whitespace-nowrap">
|
|
<i class="fas fa-code-branch mr-1"></i>Branch:
|
|
</label>
|
|
<input type="text" id="${branchInputId}"
|
|
placeholder="main (default)"
|
|
class="flex-1 px-2 py-1 text-xs border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500">
|
|
</div>
|
|
<div class="flex items-center justify-between">
|
|
<span class="text-xs text-gray-500">Last updated ${formatDate(plugin.last_updated)}</span>
|
|
${installBtn}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}).join('');
|
|
}
|
|
|
|
window.installFromCustomRegistry = function(pluginId, registryUrl, pluginPath, branch = null) {
|
|
const repoUrl = registryUrl;
|
|
const requestBody = {
|
|
repo_url: repoUrl,
|
|
plugin_id: pluginId,
|
|
plugin_path: pluginPath
|
|
};
|
|
if (branch) {
|
|
requestBody.branch = branch;
|
|
}
|
|
|
|
fetch('/api/v3/plugins/install-from-url', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify(requestBody)
|
|
})
|
|
.then(response => response.json())
|
|
.then(data => {
|
|
if (data.status === 'success') {
|
|
showSuccess(`Plugin ${data.plugin_id} installed successfully`);
|
|
// Refresh installed plugins and re-render custom registry
|
|
loadInstalledPlugins();
|
|
// Re-render custom registry to update install buttons
|
|
const registryUrlInput = document.getElementById('github-registry-url');
|
|
if (registryUrlInput && registryUrlInput.value.trim()) {
|
|
document.getElementById('load-registry-from-url').click();
|
|
}
|
|
} else {
|
|
showError(data.message || 'Installation failed');
|
|
}
|
|
})
|
|
.catch(error => {
|
|
let errorMsg = 'Error installing plugin: ' + error.message;
|
|
if (error.message && error.message.includes('Failed to Fetch')) {
|
|
errorMsg += ' - Please try refreshing your browser.';
|
|
}
|
|
showError(errorMsg);
|
|
});
|
|
}
|
|
|
|
function showSuccess(message) {
|
|
// Try to use notification system if available, otherwise use alert
|
|
if (typeof showNotification === 'function') {
|
|
showNotification(message, 'success');
|
|
} else {
|
|
console.log('Success: ' + message);
|
|
// Show a temporary success message
|
|
const statusDiv = document.getElementById('github-plugin-status') || document.getElementById('registry-status');
|
|
if (statusDiv) {
|
|
statusDiv.innerHTML = `<span class="text-green-600"><i class="fas fa-check-circle mr-1"></i>${message}</span>`;
|
|
setTimeout(() => {
|
|
if (statusDiv) statusDiv.innerHTML = '';
|
|
}, 5000);
|
|
}
|
|
}
|
|
}
|
|
|
|
function showError(message) {
|
|
const content = document.getElementById('plugins-content');
|
|
if (!content) {
|
|
console.error('plugins-content element not found');
|
|
if (typeof showNotification === 'function') {
|
|
showNotification(message, 'error');
|
|
} else {
|
|
console.error('Error: ' + message);
|
|
}
|
|
return;
|
|
}
|
|
content.innerHTML = `
|
|
<div class="text-center py-8">
|
|
<i class="fas fa-exclamation-triangle text-4xl text-red-400 mb-2"></i>
|
|
<p class="text-red-600">${escapeHtml(message)}</p>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
// Plugin configuration form submission
|
|
document.addEventListener('submit', function(e) {
|
|
if (e.target.id === 'plugin-config-form') {
|
|
e.preventDefault();
|
|
|
|
const formData = new FormData(e.target);
|
|
const config = {};
|
|
const schema = currentPluginConfig?.schema;
|
|
|
|
// Convert form data to config object
|
|
// Note: 'enabled' is managed separately via the header toggle, not through this form
|
|
for (let [key, value] of formData.entries()) {
|
|
// Skip enabled - it's managed separately via the header toggle
|
|
if (key === 'enabled') continue;
|
|
|
|
// Check if this field is an array type in the schema
|
|
if (schema?.properties?.[key]?.type === 'array') {
|
|
// Convert comma-separated string to array
|
|
const arrayValue = value.split(',').map(item => item.trim()).filter(item => item.length > 0);
|
|
config[key] = arrayValue;
|
|
console.log(`Array field ${key}: "${value}" -> `, arrayValue);
|
|
} else if (key === 'display_duration' || schema?.properties?.[key]?.type === 'integer') {
|
|
config[key] = parseInt(value);
|
|
} else if (schema?.properties?.[key]?.type === 'number') {
|
|
config[key] = parseFloat(value);
|
|
} else if (schema?.properties?.[key]?.type === 'boolean') {
|
|
config[key] = value === 'true' || value === true;
|
|
} else {
|
|
config[key] = value;
|
|
}
|
|
}
|
|
|
|
console.log('Final config to save:', config);
|
|
console.log('Schema loaded:', schema ? 'Yes' : 'No');
|
|
|
|
// Save the configuration
|
|
savePluginConfiguration(currentPluginConfig.pluginId, config);
|
|
}
|
|
});
|
|
|
|
function savePluginConfiguration(pluginId, config) {
|
|
// Update the plugin configuration in the backend
|
|
fetch('/api/v3/plugins/config', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ plugin_id: pluginId, config })
|
|
})
|
|
.then(response => {
|
|
if (!response.ok) {
|
|
// Try to parse error response
|
|
return response.json().then(data => {
|
|
// Return error data with status
|
|
return { error: true, status: response.status, ...data };
|
|
}).catch(() => {
|
|
// If JSON parsing fails, return generic error
|
|
return {
|
|
error: true,
|
|
status: response.status,
|
|
message: `Server error: ${response.status} ${response.statusText}`
|
|
};
|
|
});
|
|
}
|
|
return response.json();
|
|
})
|
|
.then(data => {
|
|
if (data.error || data.status !== 'success') {
|
|
// Display validation errors if present
|
|
if (data.validation_errors && Array.isArray(data.validation_errors)) {
|
|
displayValidationErrors(data.validation_errors);
|
|
}
|
|
let errorMessage = data.message || 'Error saving configuration';
|
|
if (data.validation_errors && Array.isArray(data.validation_errors) && data.validation_errors.length > 0) {
|
|
errorMessage += '\n\nValidation errors:\n' + data.validation_errors.join('\n');
|
|
}
|
|
showNotification(errorMessage, 'error');
|
|
console.error('Config save failed:', data);
|
|
} else {
|
|
// Hide validation errors on success
|
|
displayValidationErrors([]);
|
|
showNotification(data.message || 'Configuration saved successfully', data.status);
|
|
closePluginConfigModal();
|
|
// Refresh the installed plugins to update the UI
|
|
loadInstalledPlugins();
|
|
}
|
|
})
|
|
.catch(error => {
|
|
console.error('Error saving plugin config:', error);
|
|
showNotification('Error saving plugin configuration: ' + error.message, 'error');
|
|
});
|
|
}
|
|
|
|
// Utility function to escape HTML
|
|
function escapeHtml(text) {
|
|
const div = document.createElement('div');
|
|
div.textContent = text;
|
|
return div.innerHTML;
|
|
}
|
|
|
|
// Format date for display
|
|
function formatDate(dateString) {
|
|
if (!dateString) return 'Unknown';
|
|
|
|
try {
|
|
const date = new Date(dateString);
|
|
const now = new Date();
|
|
const diffTime = Math.abs(now - date);
|
|
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
|
|
|
if (diffDays < 1) {
|
|
return 'Today';
|
|
} else if (diffDays < 2) {
|
|
return 'Yesterday';
|
|
} else if (diffDays < 7) {
|
|
return `${diffDays} days ago`;
|
|
} else if (diffDays < 30) {
|
|
const weeks = Math.floor(diffDays / 7);
|
|
return `${weeks} ${weeks === 1 ? 'week' : 'weeks'} ago`;
|
|
} else {
|
|
// Return formatted date for older items
|
|
return date.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' });
|
|
}
|
|
} catch (e) {
|
|
return dateString;
|
|
}
|
|
}
|
|
|
|
function formatCommit(commit, branch) {
|
|
const shortCommit = commit ? String(commit).substring(0, 7) : '';
|
|
const branchText = branch ? String(branch) : '';
|
|
|
|
if (branchText && shortCommit) {
|
|
return `${branchText} · ${shortCommit}`;
|
|
}
|
|
if (branchText) {
|
|
return branchText;
|
|
}
|
|
if (shortCommit) {
|
|
return shortCommit;
|
|
}
|
|
return 'Latest';
|
|
}
|
|
|
|
// Check if plugin is new (updated within last 7 days)
|
|
function isNewPlugin(lastUpdated) {
|
|
if (!lastUpdated) return false;
|
|
|
|
try {
|
|
const date = new Date(lastUpdated);
|
|
const now = new Date();
|
|
const diffTime = Math.abs(now - date);
|
|
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
|
|
|
return diffDays <= 7;
|
|
} catch (e) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// Debounce utility
|
|
function debounce(func, wait) {
|
|
let timeout;
|
|
return function executedFunction(...args) {
|
|
const later = () => {
|
|
clearTimeout(timeout);
|
|
func(...args);
|
|
};
|
|
clearTimeout(timeout);
|
|
timeout = setTimeout(later, wait);
|
|
};
|
|
}
|
|
|
|
// Toggle password visibility for secret fields
|
|
function togglePasswordVisibility(fieldId) {
|
|
const input = document.getElementById(fieldId);
|
|
const icon = document.getElementById(fieldId + '-icon');
|
|
|
|
if (input && icon) {
|
|
if (input.type === 'password') {
|
|
input.type = 'text';
|
|
icon.classList.remove('fa-eye');
|
|
icon.classList.add('fa-eye-slash');
|
|
} else {
|
|
input.type = 'password';
|
|
icon.classList.remove('fa-eye-slash');
|
|
icon.classList.add('fa-eye');
|
|
}
|
|
}
|
|
}
|
|
|
|
// GitHub Token Configuration Functions
|
|
window.toggleGithubTokenSettings = function() {
|
|
const settings = document.getElementById('github-token-settings');
|
|
const warning = document.getElementById('github-auth-warning');
|
|
if (settings) {
|
|
// Remove inline style if present to avoid conflicts
|
|
if (settings.style.display !== undefined) {
|
|
settings.style.display = '';
|
|
}
|
|
// Toggle Tailwind hidden class
|
|
settings.classList.toggle('hidden');
|
|
|
|
const isOpening = !settings.classList.contains('hidden');
|
|
|
|
// When opening settings, hide the warning banner (they're now combined)
|
|
if (isOpening && warning) {
|
|
warning.classList.add('hidden');
|
|
// Clear any dismissal state since user is actively configuring
|
|
sessionStorage.removeItem('github-auth-warning-dismissed');
|
|
}
|
|
|
|
// Load token when opening the panel
|
|
if (isOpening) {
|
|
loadGithubToken();
|
|
}
|
|
}
|
|
}
|
|
|
|
window.toggleGithubTokenVisibility = function() {
|
|
const input = document.getElementById('github-token-input');
|
|
const icon = document.getElementById('github-token-icon');
|
|
|
|
if (input && icon) {
|
|
if (input.type === 'password') {
|
|
input.type = 'text';
|
|
icon.classList.remove('fa-eye');
|
|
icon.classList.add('fa-eye-slash');
|
|
} else {
|
|
input.type = 'password';
|
|
icon.classList.remove('fa-eye-slash');
|
|
icon.classList.add('fa-eye');
|
|
}
|
|
}
|
|
}
|
|
|
|
window.loadGithubToken = function() {
|
|
const input = document.getElementById('github-token-input');
|
|
const loadButton = document.querySelector('button[onclick="loadGithubToken()"]');
|
|
|
|
if (!input) return;
|
|
|
|
// Set loading state on load button
|
|
const originalButtonContent = loadButton ? loadButton.innerHTML : '';
|
|
if (loadButton) {
|
|
loadButton.disabled = true;
|
|
loadButton.classList.add('opacity-50', 'cursor-not-allowed');
|
|
loadButton.innerHTML = '<i class="fas fa-spinner fa-spin mr-1"></i>Loading...';
|
|
}
|
|
|
|
fetch('/api/v3/config/secrets')
|
|
.then(response => {
|
|
if (!response.ok) {
|
|
throw new Error(`HTTP error! status: ${response.status}`);
|
|
}
|
|
return response.json();
|
|
})
|
|
.then(data => {
|
|
if (data.status === 'success') {
|
|
// Handle empty data (secrets file doesn't exist) - API returns {} in this case
|
|
const secrets = data.data || {};
|
|
const token = secrets.github?.api_token || '';
|
|
|
|
if (input) {
|
|
if (token && token !== 'YOUR_GITHUB_PERSONAL_ACCESS_TOKEN') {
|
|
// Token exists and is valid
|
|
input.value = token;
|
|
showNotification('GitHub token loaded successfully', 'success');
|
|
} else {
|
|
// No token configured or placeholder value
|
|
input.value = '';
|
|
showNotification('No GitHub token configured. Enter a new token to save.', 'info');
|
|
}
|
|
}
|
|
} else {
|
|
throw new Error(data.message || 'Failed to load secrets configuration');
|
|
}
|
|
})
|
|
.catch(error => {
|
|
console.error('Error loading GitHub token:', error);
|
|
if (input) {
|
|
input.value = '';
|
|
}
|
|
// If it's a 404 or file doesn't exist, that's okay - just inform the user
|
|
if (error.message.includes('404') || error.message.includes('not found')) {
|
|
showNotification('No secrets file found. You can create one by saving a token.', 'info');
|
|
} else {
|
|
showNotification('Error loading GitHub token: ' + error.message, 'error');
|
|
}
|
|
})
|
|
.finally(() => {
|
|
// Restore button state
|
|
if (loadButton) {
|
|
loadButton.disabled = false;
|
|
loadButton.classList.remove('opacity-50', 'cursor-not-allowed');
|
|
loadButton.innerHTML = originalButtonContent;
|
|
}
|
|
});
|
|
}
|
|
|
|
window.saveGithubToken = function() {
|
|
const input = document.getElementById('github-token-input');
|
|
const saveButton = document.querySelector('button[onclick="saveGithubToken()"]');
|
|
if (!input) return;
|
|
|
|
const token = input.value.trim();
|
|
|
|
if (!token) {
|
|
showNotification('Please enter a GitHub token', 'error');
|
|
return;
|
|
}
|
|
|
|
// Client-side token validation
|
|
if (!token.startsWith('ghp_') && !token.startsWith('github_pat_')) {
|
|
if (!confirm('Token format looks invalid. GitHub tokens should start with "ghp_" or "github_pat_". Continue anyway?')) {
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Set loading state on save button
|
|
const originalButtonContent = saveButton ? saveButton.innerHTML : '';
|
|
if (saveButton) {
|
|
saveButton.disabled = true;
|
|
saveButton.classList.add('opacity-50', 'cursor-not-allowed');
|
|
saveButton.innerHTML = '<i class="fas fa-spinner fa-spin mr-1"></i>Saving...';
|
|
}
|
|
|
|
// Load current secrets config
|
|
fetch('/api/v3/config/secrets')
|
|
.then(response => {
|
|
if (!response.ok) {
|
|
throw new Error(`HTTP error! status: ${response.status}`);
|
|
}
|
|
return response.json();
|
|
})
|
|
.then(data => {
|
|
if (data.status === 'success') {
|
|
const secrets = data.data || {};
|
|
|
|
// Update GitHub token
|
|
if (!secrets.github) {
|
|
secrets.github = {};
|
|
}
|
|
secrets.github.api_token = token;
|
|
|
|
// Save updated secrets
|
|
return fetch('/api/v3/config/raw/secrets', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(secrets)
|
|
});
|
|
} else {
|
|
throw new Error(data.message || 'Failed to load current secrets');
|
|
}
|
|
})
|
|
.then(response => {
|
|
if (!response.ok) {
|
|
throw new Error(`HTTP error! status: ${response.status}`);
|
|
}
|
|
return response.json();
|
|
})
|
|
.then(data => {
|
|
if (data.status === 'success') {
|
|
showNotification('GitHub token saved successfully! Rate limit increased to 5,000/hour', 'success');
|
|
|
|
// Clear input field for security (user can reload if needed)
|
|
input.value = '';
|
|
|
|
// Refresh GitHub auth status to update UI (this will hide warning and settings)
|
|
checkGitHubAuthStatus();
|
|
|
|
// Hide the settings panel after successful save
|
|
setTimeout(() => {
|
|
toggleGithubTokenSettings();
|
|
}, 1500);
|
|
} else {
|
|
throw new Error(data.message || 'Failed to save token');
|
|
}
|
|
})
|
|
.catch(error => {
|
|
console.error('Error saving GitHub token:', error);
|
|
showNotification('Error saving GitHub token: ' + error.message, 'error');
|
|
})
|
|
.finally(() => {
|
|
// Restore button state
|
|
if (saveButton) {
|
|
saveButton.disabled = false;
|
|
saveButton.classList.remove('opacity-50', 'cursor-not-allowed');
|
|
saveButton.innerHTML = originalButtonContent;
|
|
}
|
|
});
|
|
}
|
|
|
|
// GitHub Authentication Status
|
|
// Only shows the warning banner if no GitHub token is configured
|
|
// The token itself is never exposed to the frontend for security
|
|
function checkGitHubAuthStatus() {
|
|
fetch('/api/v3/plugins/store/github-status')
|
|
.then(response => response.json())
|
|
.then(data => {
|
|
if (data.status === 'success') {
|
|
const authData = data.data;
|
|
|
|
// Only show the banner if no GitHub token is configured
|
|
if (!authData.authenticated) {
|
|
// Check if user has dismissed the warning (stored in session storage)
|
|
const dismissed = sessionStorage.getItem('github-auth-warning-dismissed');
|
|
if (!dismissed) {
|
|
const warning = document.getElementById('github-auth-warning');
|
|
const rateLimit = document.getElementById('rate-limit-count');
|
|
|
|
if (warning && rateLimit) {
|
|
rateLimit.textContent = authData.rate_limit;
|
|
warning.classList.remove('hidden');
|
|
console.log('GitHub token not configured - showing API limit warning');
|
|
}
|
|
}
|
|
} else {
|
|
// Token is configured - hide both warning and settings
|
|
const warning = document.getElementById('github-auth-warning');
|
|
const settings = document.getElementById('github-token-settings');
|
|
|
|
if (warning) {
|
|
warning.classList.add('hidden');
|
|
console.log('GitHub token is configured - API limit warning hidden');
|
|
}
|
|
|
|
if (settings) {
|
|
settings.classList.add('hidden');
|
|
console.log('GitHub token is configured - hiding settings panel');
|
|
}
|
|
}
|
|
}
|
|
})
|
|
.catch(error => {
|
|
console.error('Error checking GitHub auth status:', error);
|
|
});
|
|
}
|
|
|
|
window.dismissGithubWarning = function() {
|
|
const warning = document.getElementById('github-auth-warning');
|
|
const settings = document.getElementById('github-token-settings');
|
|
if (warning) {
|
|
warning.classList.add('hidden');
|
|
// Also hide settings if it's open (since they're combined now)
|
|
if (settings && !settings.classList.contains('hidden')) {
|
|
settings.classList.add('hidden');
|
|
}
|
|
// Remember dismissal for this session
|
|
sessionStorage.setItem('github-auth-warning-dismissed', 'true');
|
|
}
|
|
}
|
|
|
|
window.showGithubTokenInstructions = function() {
|
|
const instructions = `
|
|
<div class="space-y-4">
|
|
<h4 class="font-semibold text-lg">How to Add a GitHub Token</h4>
|
|
|
|
<div class="space-y-3">
|
|
<div class="bg-gray-50 p-3 rounded">
|
|
<h5 class="font-medium mb-2">Step 1: Create a GitHub Token</h5>
|
|
<ol class="list-decimal list-inside space-y-1 text-sm">
|
|
<li>Click the "Create a GitHub Token" link above (or <a href="https://github.com/settings/tokens/new?description=LEDMatrix%20Plugin%20Manager&scopes=" target="_blank" class="text-blue-600 underline">click here</a>)</li>
|
|
<li>Give it a name like "LEDMatrix Plugin Manager"</li>
|
|
<li>No special scopes/permissions are needed for public repositories</li>
|
|
<li>Click "Generate token" at the bottom</li>
|
|
<li>Copy the generated token (it starts with "ghp_")</li>
|
|
</ol>
|
|
</div>
|
|
|
|
<div class="bg-gray-50 p-3 rounded">
|
|
<h5 class="font-medium mb-2">Step 2: Add Token to LEDMatrix</h5>
|
|
<ol class="list-decimal list-inside space-y-1 text-sm">
|
|
<li>SSH into your Raspberry Pi</li>
|
|
<li>Edit the secrets file: <code class="bg-gray-200 px-1 rounded">nano ~/LEDMatrix/config/config_secrets.json</code></li>
|
|
<li>Find the "github" section and add your token:
|
|
<pre class="bg-gray-800 text-white p-2 rounded mt-2 text-xs overflow-x-auto">"github": {
|
|
"api_token": "ghp_your_token_here"
|
|
}</pre>
|
|
</li>
|
|
<li>Save the file (Ctrl+O, Enter, Ctrl+X)</li>
|
|
<li>Restart the web service: <code class="bg-gray-200 px-1 rounded">sudo systemctl restart ledmatrix-web</code></li>
|
|
</ol>
|
|
</div>
|
|
|
|
<div class="bg-blue-50 p-3 rounded border border-blue-200">
|
|
<p class="text-sm text-blue-800">
|
|
<i class="fas fa-info-circle mr-2"></i>
|
|
<strong>Note:</strong> Your token is stored locally and never shared. It's only used to authenticate API requests to GitHub.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="flex justify-end">
|
|
<button onclick="closeInstructionsModal()" class="btn bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-md">
|
|
Got it!
|
|
</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
// Use the existing plugin config modal for instructions
|
|
const modal = document.getElementById('plugin-config-modal');
|
|
const title = document.getElementById('plugin-config-title');
|
|
const content = document.getElementById('plugin-config-content');
|
|
|
|
title.textContent = 'GitHub Token Setup';
|
|
content.innerHTML = instructions;
|
|
modal.style.display = 'flex';
|
|
console.log('GitHub instructions modal opened');
|
|
}
|
|
|
|
window.closeInstructionsModal = function() {
|
|
const modal = document.getElementById('plugin-config-modal');
|
|
modal.style.display = 'none';
|
|
console.log('Instructions modal closed');
|
|
}
|
|
|
|
// ==================== File Upload Functions ====================
|
|
// Make these globally accessible for use in base.html
|
|
|
|
window.handleFileDrop = function(event, fieldId) {
|
|
event.preventDefault();
|
|
const files = event.dataTransfer.files;
|
|
if (files.length > 0) {
|
|
window.handleFiles(fieldId, Array.from(files));
|
|
}
|
|
}
|
|
|
|
window.handleFileSelect = function(event, fieldId) {
|
|
const files = event.target.files;
|
|
if (files.length > 0) {
|
|
window.handleFiles(fieldId, Array.from(files));
|
|
}
|
|
}
|
|
|
|
window.handleCredentialsUpload = async function(event, fieldId, uploadEndpoint, targetFilename) {
|
|
const file = event.target.files[0];
|
|
if (!file) {
|
|
return;
|
|
}
|
|
|
|
// Validate file extension
|
|
const fileExt = '.' + file.name.split('.').pop().toLowerCase();
|
|
if (!fileExt || fileExt === '.') {
|
|
showNotification('Please select a valid file', 'error');
|
|
return;
|
|
}
|
|
|
|
// Validate file size (1MB max)
|
|
if (file.size > 1024 * 1024) {
|
|
showNotification('File exceeds 1MB limit', 'error');
|
|
return;
|
|
}
|
|
|
|
// Show upload status
|
|
const statusEl = document.getElementById(fieldId + '_status');
|
|
if (statusEl) {
|
|
statusEl.innerHTML = '<i class="fas fa-spinner fa-spin mr-2"></i>Uploading...';
|
|
}
|
|
|
|
// Create form data
|
|
const formData = new FormData();
|
|
formData.append('file', file);
|
|
|
|
try {
|
|
const response = await fetch(uploadEndpoint, {
|
|
method: 'POST',
|
|
body: formData
|
|
});
|
|
|
|
const data = await response.json();
|
|
|
|
if (data.status === 'success') {
|
|
// Update hidden input with filename
|
|
const hiddenInput = document.getElementById(fieldId + '_hidden');
|
|
if (hiddenInput) {
|
|
hiddenInput.value = targetFilename || file.name;
|
|
}
|
|
|
|
// Update status
|
|
if (statusEl) {
|
|
statusEl.innerHTML = `✓ Uploaded: ${targetFilename || file.name}`;
|
|
statusEl.className = 'text-sm text-green-600';
|
|
}
|
|
|
|
showNotification('Credentials file uploaded successfully', 'success');
|
|
} else {
|
|
if (statusEl) {
|
|
statusEl.innerHTML = 'Upload failed - click to try again';
|
|
statusEl.className = 'text-sm text-gray-600';
|
|
}
|
|
showNotification(data.message || 'Upload failed', 'error');
|
|
}
|
|
} catch (error) {
|
|
if (statusEl) {
|
|
statusEl.innerHTML = 'Upload failed - click to try again';
|
|
statusEl.className = 'text-sm text-gray-600';
|
|
}
|
|
showNotification('Error uploading file: ' + error.message, 'error');
|
|
}
|
|
}
|
|
|
|
window.handleFiles = async function(fieldId, files) {
|
|
const uploadConfig = window.getUploadConfig(fieldId);
|
|
const pluginId = uploadConfig.plugin_id || window.currentPluginConfig?.pluginId || 'static-image';
|
|
const maxFiles = uploadConfig.max_files || 10;
|
|
const maxSizeMB = uploadConfig.max_size_mb || 5;
|
|
const fileType = uploadConfig.file_type || 'image';
|
|
const customUploadEndpoint = uploadConfig.endpoint || '/api/v3/plugins/assets/upload';
|
|
|
|
// Get current files list (works for both images and JSON)
|
|
const currentFiles = window.getCurrentImages ? window.getCurrentImages(fieldId) : [];
|
|
if (currentFiles.length + files.length > maxFiles) {
|
|
showNotification(`Maximum ${maxFiles} files allowed. You have ${currentFiles.length} and tried to add ${files.length}.`, 'error');
|
|
return;
|
|
}
|
|
|
|
// Validate file types and sizes
|
|
const validFiles = [];
|
|
for (const file of files) {
|
|
if (file.size > maxSizeMB * 1024 * 1024) {
|
|
showNotification(`File ${file.name} exceeds ${maxSizeMB}MB limit`, 'error');
|
|
continue;
|
|
}
|
|
|
|
if (fileType === 'json') {
|
|
// Validate JSON files
|
|
if (!file.name.toLowerCase().endsWith('.json')) {
|
|
showNotification(`File ${file.name} must be a JSON file (.json)`, 'error');
|
|
continue;
|
|
}
|
|
} else {
|
|
// Validate image files
|
|
const allowedTypes = ['image/png', 'image/jpeg', 'image/jpg', 'image/bmp', 'image/gif'];
|
|
if (!allowedTypes.includes(file.type)) {
|
|
showNotification(`File ${file.name} is not a valid image type`, 'error');
|
|
continue;
|
|
}
|
|
}
|
|
|
|
validFiles.push(file);
|
|
}
|
|
|
|
if (validFiles.length === 0) {
|
|
return;
|
|
}
|
|
|
|
// Show upload progress
|
|
window.showUploadProgress(fieldId, validFiles.length);
|
|
|
|
// Upload files
|
|
const formData = new FormData();
|
|
if (fileType !== 'json') {
|
|
formData.append('plugin_id', pluginId);
|
|
}
|
|
validFiles.forEach(file => formData.append('files', file));
|
|
|
|
try {
|
|
const response = await fetch(customUploadEndpoint, {
|
|
method: 'POST',
|
|
body: formData
|
|
});
|
|
|
|
const data = await response.json();
|
|
|
|
if (data.status === 'success') {
|
|
// Add uploaded files to current list
|
|
const currentFiles = window.getCurrentImages ? window.getCurrentImages(fieldId) : [];
|
|
const newFiles = [...currentFiles, ...data.uploaded_files];
|
|
window.updateImageList(fieldId, newFiles);
|
|
|
|
showNotification(`Successfully uploaded ${data.uploaded_files.length} ${fileType === 'json' ? 'file(s)' : 'image(s)'}`, 'success');
|
|
} else {
|
|
showNotification(`Upload failed: ${data.message}`, 'error');
|
|
}
|
|
} catch (error) {
|
|
console.error('Upload error:', error);
|
|
showNotification(`Upload error: ${error.message}`, 'error');
|
|
} finally {
|
|
window.hideUploadProgress(fieldId);
|
|
// Clear file input
|
|
const fileInput = document.getElementById(`${fieldId}_file_input`);
|
|
if (fileInput) {
|
|
fileInput.value = '';
|
|
}
|
|
}
|
|
}
|
|
|
|
window.deleteUploadedImage = async function(fieldId, imageId, pluginId) {
|
|
return window.deleteUploadedFile(fieldId, imageId, pluginId, 'image', null);
|
|
}
|
|
|
|
window.deleteUploadedFile = async function(fieldId, fileId, pluginId, fileType, customDeleteEndpoint) {
|
|
const fileTypeLabel = fileType === 'json' ? 'file' : 'image';
|
|
if (!confirm(`Are you sure you want to delete this ${fileTypeLabel}?`)) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const deleteEndpoint = customDeleteEndpoint || (fileType === 'json' ? '/api/v3/plugins/of-the-day/json/delete' : '/api/v3/plugins/assets/delete');
|
|
const requestBody = fileType === 'json'
|
|
? { file_id: fileId }
|
|
: { plugin_id: pluginId, image_id: fileId };
|
|
|
|
const response = await fetch(deleteEndpoint, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(requestBody)
|
|
});
|
|
|
|
const data = await response.json();
|
|
|
|
if (data.status === 'success') {
|
|
// Remove from current list
|
|
const currentFiles = window.getCurrentImages ? window.getCurrentImages(fieldId) : [];
|
|
const newFiles = currentFiles.filter(file => (file.id || file.category_name) !== fileId);
|
|
window.updateImageList(fieldId, newFiles);
|
|
|
|
showNotification(`${fileType === 'json' ? 'File' : 'Image'} deleted successfully`, 'success');
|
|
} else {
|
|
showNotification(`Delete failed: ${data.message}`, 'error');
|
|
}
|
|
} catch (error) {
|
|
console.error('Delete error:', error);
|
|
showNotification(`Delete error: ${error.message}`, 'error');
|
|
}
|
|
}
|
|
|
|
window.getUploadConfig = function(fieldId) {
|
|
// Extract config from schema
|
|
const schema = window.currentPluginConfig?.schema;
|
|
if (!schema || !schema.properties) return {};
|
|
|
|
// Find the property that matches this fieldId
|
|
// FieldId is like "image_config_images" for "image_config.images"
|
|
const key = fieldId.replace(/_/g, '.');
|
|
const keys = key.split('.');
|
|
let prop = schema.properties;
|
|
|
|
for (const k of keys) {
|
|
if (prop && prop[k]) {
|
|
prop = prop[k];
|
|
if (prop.properties && prop.type === 'object') {
|
|
prop = prop.properties;
|
|
} else if (prop.type === 'array' && prop['x-widget'] === 'file-upload') {
|
|
break;
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
// If we found an array with x-widget, get its config
|
|
if (prop && prop.type === 'array' && prop['x-widget'] === 'file-upload') {
|
|
return prop['x-upload-config'] || {};
|
|
}
|
|
|
|
// Try to find nested images array
|
|
if (schema.properties && schema.properties.image_config &&
|
|
schema.properties.image_config.properties &&
|
|
schema.properties.image_config.properties.images) {
|
|
const imagesProp = schema.properties.image_config.properties.images;
|
|
if (imagesProp['x-widget'] === 'file-upload') {
|
|
return imagesProp['x-upload-config'] || {};
|
|
}
|
|
}
|
|
|
|
return {};
|
|
}
|
|
|
|
window.getCurrentImages = function(fieldId) {
|
|
const hiddenInput = document.getElementById(`${fieldId}_images_data`);
|
|
if (hiddenInput && hiddenInput.value) {
|
|
try {
|
|
return JSON.parse(hiddenInput.value);
|
|
} catch (e) {
|
|
console.error('Error parsing images data:', e);
|
|
}
|
|
}
|
|
return [];
|
|
}
|
|
|
|
window.updateImageList = function(fieldId, images) {
|
|
const hiddenInput = document.getElementById(`${fieldId}_images_data`);
|
|
if (hiddenInput) {
|
|
hiddenInput.value = JSON.stringify(images);
|
|
}
|
|
|
|
// Update the display
|
|
const imageList = document.getElementById(`${fieldId}_image_list`);
|
|
if (imageList) {
|
|
const uploadConfig = window.getUploadConfig(fieldId);
|
|
const pluginId = uploadConfig.plugin_id || window.currentPluginConfig?.pluginId || 'static-image';
|
|
|
|
imageList.innerHTML = images.map((img, idx) => {
|
|
const imgSchedule = img.schedule || {};
|
|
const hasSchedule = imgSchedule.enabled && imgSchedule.mode && imgSchedule.mode !== 'always';
|
|
const scheduleSummary = hasSchedule ? (window.getScheduleSummary ? window.getScheduleSummary(imgSchedule) : 'Scheduled') : 'Always shown';
|
|
|
|
return `
|
|
<div id="img_${img.id || idx}" 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 || ''}"
|
|
alt="${img.filename || ''}"
|
|
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">${img.original_filename || img.filename || 'Image'}</p>
|
|
<p class="text-xs text-gray-500">${window.formatFileSize ? window.formatFileSize(img.size || 0) : (Math.round((img.size || 0) / 1024) + ' KB')} • ${window.formatDate ? window.formatDate(img.uploaded_at) : (img.uploaded_at || '')}</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"
|
|
onclick="window.openImageSchedule('${fieldId}', '${img.id}', ${idx})"
|
|
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"
|
|
onclick="window.deleteUploadedImage('${fieldId}', '${img.id}', '${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}" class="hidden mt-3 pt-3 border-t border-gray-300"></div>
|
|
</div>
|
|
`;
|
|
}).join('');
|
|
}
|
|
}
|
|
|
|
window.showUploadProgress = function(fieldId, totalFiles) {
|
|
const dropZone = document.getElementById(`${fieldId}_drop_zone`);
|
|
if (dropZone) {
|
|
dropZone.innerHTML = `
|
|
<i class="fas fa-spinner fa-spin text-3xl text-blue-500 mb-2"></i>
|
|
<p class="text-sm text-gray-600">Uploading ${totalFiles} file(s)...</p>
|
|
`;
|
|
dropZone.style.pointerEvents = 'none';
|
|
}
|
|
}
|
|
|
|
window.hideUploadProgress = function(fieldId) {
|
|
const uploadConfig = window.getUploadConfig(fieldId);
|
|
const maxFiles = uploadConfig.max_files || 10;
|
|
const maxSizeMB = uploadConfig.max_size_mb || 5;
|
|
const allowedTypes = uploadConfig.allowed_types || ['image/png', 'image/jpeg', 'image/bmp', 'image/gif'];
|
|
|
|
const dropZone = document.getElementById(`${fieldId}_drop_zone`);
|
|
if (dropZone) {
|
|
dropZone.innerHTML = `
|
|
<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>
|
|
`;
|
|
dropZone.style.pointerEvents = 'auto';
|
|
}
|
|
}
|
|
|
|
window.formatFileSize = function(bytes) {
|
|
if (bytes === 0) return '0 B';
|
|
const k = 1024;
|
|
const sizes = ['B', 'KB', 'MB'];
|
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
|
|
}
|
|
|
|
function formatDate(dateString) {
|
|
if (!dateString) return 'Unknown date';
|
|
try {
|
|
const date = new Date(dateString);
|
|
return date.toLocaleDateString() + ' ' + date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
|
} catch (e) {
|
|
return dateString;
|
|
}
|
|
}
|
|
|
|
window.getScheduleSummary = function(schedule) {
|
|
if (!schedule || !schedule.enabled || schedule.mode === 'always') {
|
|
return 'Always shown';
|
|
}
|
|
|
|
if (schedule.mode === 'time_range') {
|
|
return `${schedule.start_time || '08:00'} - ${schedule.end_time || '18:00'} (daily)`;
|
|
}
|
|
|
|
if (schedule.mode === 'per_day' && schedule.days) {
|
|
const enabledDays = Object.entries(schedule.days)
|
|
.filter(([day, config]) => config && config.enabled)
|
|
.map(([day]) => day.charAt(0).toUpperCase() + day.slice(1, 3));
|
|
|
|
if (enabledDays.length === 0) {
|
|
return 'Never shown';
|
|
}
|
|
|
|
return enabledDays.join(', ') + ' only';
|
|
}
|
|
|
|
return 'Scheduled';
|
|
}
|
|
|
|
window.openImageSchedule = function(fieldId, imageId, imageIdx) {
|
|
const currentImages = getCurrentImages(fieldId);
|
|
const image = currentImages[imageIdx];
|
|
if (!image) return;
|
|
|
|
const scheduleContainer = document.getElementById(`schedule_${imageId || imageIdx}`);
|
|
if (!scheduleContainer) return;
|
|
|
|
// Toggle visibility
|
|
const isVisible = !scheduleContainer.classList.contains('hidden');
|
|
|
|
if (isVisible) {
|
|
scheduleContainer.classList.add('hidden');
|
|
return;
|
|
}
|
|
|
|
scheduleContainer.classList.remove('hidden');
|
|
|
|
const schedule = image.schedule || { enabled: false, mode: 'always', start_time: '08:00', end_time: '18:00', days: {} };
|
|
|
|
scheduleContainer.innerHTML = `
|
|
<div class="bg-white rounded-lg border border-blue-200 p-4">
|
|
<h4 class="text-sm font-semibold text-gray-900 mb-3">
|
|
<i class="fas fa-clock mr-2"></i>Schedule Settings
|
|
</h4>
|
|
|
|
<!-- Enable Schedule -->
|
|
<div class="mb-4">
|
|
<label class="flex items-center">
|
|
<input type="checkbox"
|
|
id="schedule_enabled_${imageId}"
|
|
${schedule.enabled ? 'checked' : ''}
|
|
onchange="window.toggleImageScheduleEnabled('${fieldId}', '${imageId}', ${imageIdx})"
|
|
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
|
|
<span class="ml-2 text-sm font-medium text-gray-700">Enable schedule for this image</span>
|
|
</label>
|
|
<p class="ml-6 text-xs text-gray-500 mt-1">When enabled, this image will only display during scheduled times</p>
|
|
</div>
|
|
|
|
<!-- Schedule Mode -->
|
|
<div id="schedule_options_${imageId}" class="space-y-4" style="display: ${schedule.enabled ? 'block' : 'none'};">
|
|
<div>
|
|
<label class="block text-sm font-medium text-gray-700 mb-2">Schedule Type</label>
|
|
<select id="schedule_mode_${imageId}"
|
|
onchange="window.updateImageScheduleMode('${fieldId}', '${imageId}', ${imageIdx})"
|
|
class="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">
|
|
<option value="always" ${schedule.mode === 'always' ? 'selected' : ''}>Always Show (No Schedule)</option>
|
|
<option value="time_range" ${schedule.mode === 'time_range' ? 'selected' : ''}>Same Time Every Day</option>
|
|
<option value="per_day" ${schedule.mode === 'per_day' ? 'selected' : ''}>Different Times Per Day</option>
|
|
</select>
|
|
</div>
|
|
|
|
<!-- Time Range Mode -->
|
|
<div id="time_range_${imageId}" class="grid grid-cols-2 gap-4" style="display: ${schedule.mode === 'time_range' ? 'grid' : 'none'};">
|
|
<div>
|
|
<label class="block text-xs font-medium text-gray-700 mb-1">Start Time</label>
|
|
<input type="time"
|
|
id="schedule_start_${imageId}"
|
|
value="${schedule.start_time || '08:00'}"
|
|
onchange="window.updateImageScheduleTime('${fieldId}', '${imageId}', ${imageIdx})"
|
|
class="block w-full px-2 py-1 text-sm border border-gray-300 rounded-md">
|
|
</div>
|
|
<div>
|
|
<label class="block text-xs font-medium text-gray-700 mb-1">End Time</label>
|
|
<input type="time"
|
|
id="schedule_end_${imageId}"
|
|
value="${schedule.end_time || '18:00'}"
|
|
onchange="window.updateImageScheduleTime('${fieldId}', '${imageId}', ${imageIdx})"
|
|
class="block w-full px-2 py-1 text-sm border border-gray-300 rounded-md">
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Per-Day Mode -->
|
|
<div id="per_day_${imageId}" style="display: ${schedule.mode === 'per_day' ? 'block' : 'none'};">
|
|
<label class="block text-xs font-medium text-gray-700 mb-2">Day-Specific Times</label>
|
|
<div class="bg-gray-50 rounded p-3 space-y-2 max-h-64 overflow-y-auto">
|
|
${['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'].map(day => {
|
|
const dayConfig = (schedule.days && schedule.days[day]) || { enabled: true, start_time: '08:00', end_time: '18:00' };
|
|
return `
|
|
<div class="bg-white rounded p-2 border border-gray-200">
|
|
<div class="flex items-center justify-between mb-2">
|
|
<label class="flex items-center">
|
|
<input type="checkbox"
|
|
id="day_${day}_${imageId}"
|
|
${dayConfig.enabled ? 'checked' : ''}
|
|
onchange="window.updateImageScheduleDay('${fieldId}', '${imageId}', ${imageIdx}, '${day}')"
|
|
class="h-3 w-3 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
|
|
<span class="ml-2 text-xs font-medium text-gray-700 capitalize">${day}</span>
|
|
</label>
|
|
</div>
|
|
<div class="grid grid-cols-2 gap-2 ml-5" id="day_times_${day}_${imageId}" style="display: ${dayConfig.enabled ? 'grid' : 'none'};">
|
|
<input type="time"
|
|
id="day_${day}_start_${imageId}"
|
|
value="${dayConfig.start_time || '08:00'}"
|
|
onchange="updateImageScheduleDay('${fieldId}', '${imageId}', ${imageIdx}, '${day}')"
|
|
class="text-xs px-2 py-1 border border-gray-300 rounded"
|
|
${!dayConfig.enabled ? 'disabled' : ''}>
|
|
<input type="time"
|
|
id="day_${day}_end_${imageId}"
|
|
value="${dayConfig.end_time || '18:00'}"
|
|
onchange="updateImageScheduleDay('${fieldId}', '${imageId}', ${imageIdx}, '${day}')"
|
|
class="text-xs px-2 py-1 border border-gray-300 rounded"
|
|
${!dayConfig.enabled ? 'disabled' : ''}>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}).join('')}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
window.toggleImageScheduleEnabled = function(fieldId, imageId, imageIdx) {
|
|
const currentImages = window.getCurrentImages(fieldId);
|
|
const image = currentImages[imageIdx];
|
|
if (!image) return;
|
|
|
|
const checkbox = document.getElementById(`schedule_enabled_${imageId}`);
|
|
const enabled = checkbox.checked;
|
|
|
|
if (!image.schedule) {
|
|
image.schedule = { enabled: false, mode: 'always', start_time: '08:00', end_time: '18:00', days: {} };
|
|
}
|
|
|
|
image.schedule.enabled = enabled;
|
|
|
|
const optionsDiv = document.getElementById(`schedule_options_${imageId}`);
|
|
if (optionsDiv) {
|
|
optionsDiv.style.display = enabled ? 'block' : 'none';
|
|
}
|
|
|
|
window.updateImageList(fieldId, currentImages);
|
|
}
|
|
|
|
window.updateImageScheduleMode = function(fieldId, imageId, imageIdx) {
|
|
const currentImages = window.getCurrentImages(fieldId);
|
|
const image = currentImages[imageIdx];
|
|
if (!image) return;
|
|
|
|
if (!image.schedule) {
|
|
image.schedule = { enabled: true, mode: 'always', start_time: '08:00', end_time: '18:00', days: {} };
|
|
}
|
|
|
|
const modeSelect = document.getElementById(`schedule_mode_${imageId}`);
|
|
const mode = modeSelect.value;
|
|
|
|
image.schedule.mode = mode;
|
|
|
|
const timeRangeDiv = document.getElementById(`time_range_${imageId}`);
|
|
const perDayDiv = document.getElementById(`per_day_${imageId}`);
|
|
|
|
if (timeRangeDiv) timeRangeDiv.style.display = mode === 'time_range' ? 'grid' : 'none';
|
|
if (perDayDiv) perDayDiv.style.display = mode === 'per_day' ? 'block' : 'none';
|
|
|
|
window.updateImageList(fieldId, currentImages);
|
|
}
|
|
|
|
window.updateImageScheduleTime = function(fieldId, imageId, imageIdx) {
|
|
const currentImages = window.getCurrentImages(fieldId);
|
|
const image = currentImages[imageIdx];
|
|
if (!image) return;
|
|
|
|
if (!image.schedule) {
|
|
image.schedule = { enabled: true, mode: 'time_range', start_time: '08:00', end_time: '18:00' };
|
|
}
|
|
|
|
const startInput = document.getElementById(`schedule_start_${imageId}`);
|
|
const endInput = document.getElementById(`schedule_end_${imageId}`);
|
|
|
|
if (startInput) image.schedule.start_time = startInput.value || '08:00';
|
|
if (endInput) image.schedule.end_time = endInput.value || '18:00';
|
|
|
|
window.updateImageList(fieldId, currentImages);
|
|
}
|
|
|
|
window.updateImageScheduleDay = function(fieldId, imageId, imageIdx, day) {
|
|
const currentImages = window.getCurrentImages(fieldId);
|
|
const image = currentImages[imageIdx];
|
|
if (!image) return;
|
|
|
|
if (!image.schedule) {
|
|
image.schedule = { enabled: true, mode: 'per_day', days: {} };
|
|
}
|
|
|
|
if (!image.schedule.days) {
|
|
image.schedule.days = {};
|
|
}
|
|
|
|
const checkbox = document.getElementById(`day_${day}_${imageId}`);
|
|
const startInput = document.getElementById(`day_${day}_start_${imageId}`);
|
|
const endInput = document.getElementById(`day_${day}_end_${imageId}`);
|
|
|
|
const enabled = checkbox ? checkbox.checked : true;
|
|
|
|
if (!image.schedule.days[day]) {
|
|
image.schedule.days[day] = { enabled: true, start_time: '08:00', end_time: '18:00' };
|
|
}
|
|
|
|
image.schedule.days[day].enabled = enabled;
|
|
|
|
if (startInput) image.schedule.days[day].start_time = startInput.value || '08:00';
|
|
if (endInput) image.schedule.days[day].end_time = endInput.value || '18:00';
|
|
|
|
const timesDiv = document.getElementById(`day_times_${day}_${imageId}`);
|
|
if (timesDiv) {
|
|
timesDiv.style.display = enabled ? 'grid' : 'none';
|
|
if (startInput) startInput.disabled = !enabled;
|
|
if (endInput) endInput.disabled = !enabled;
|
|
}
|
|
|
|
window.updateImageList(fieldId, currentImages);
|
|
}
|
|
|
|
})(); // End IIFE
|
|
|
|
// Make currentPluginConfig globally accessible (outside IIFE)
|
|
window.currentPluginConfig = null;
|
|
|
|
// Force initialization immediately when script loads (for HTMX swapped content)
|
|
console.log('Plugins script loaded, checking for elements...');
|
|
|
|
// Ensure all functions are globally available (in case IIFE didn't expose them properly)
|
|
// These should already be set inside the IIFE, but this ensures they're available
|
|
if (typeof initializePluginPageWhenReady !== 'undefined') {
|
|
window.initializePluginPageWhenReady = initializePluginPageWhenReady;
|
|
}
|
|
if (typeof initializePlugins !== 'undefined') {
|
|
window.initializePlugins = initializePlugins;
|
|
}
|
|
if (typeof loadInstalledPlugins !== 'undefined') {
|
|
window.loadInstalledPlugins = loadInstalledPlugins;
|
|
}
|
|
if (typeof renderInstalledPlugins !== 'undefined') {
|
|
window.renderInstalledPlugins = renderInstalledPlugins;
|
|
}
|
|
// searchPluginStore is now exposed inside the IIFE after its definition
|
|
|
|
// Verify critical functions are available
|
|
if (_PLUGIN_DEBUG_EARLY) {
|
|
console.log('Plugin functions available:', {
|
|
configurePlugin: typeof window.configurePlugin,
|
|
togglePlugin: typeof window.togglePlugin,
|
|
initializePlugins: typeof window.initializePlugins,
|
|
loadInstalledPlugins: typeof window.loadInstalledPlugins,
|
|
searchPluginStore: typeof window.searchPluginStore
|
|
});
|
|
}
|
|
|
|
setTimeout(function() {
|
|
const installedGrid = document.getElementById('installed-plugins-grid');
|
|
if (installedGrid) {
|
|
console.log('Found installed-plugins-grid, forcing initialization...');
|
|
window.pluginManager.initialized = false;
|
|
if (typeof initializePluginPageWhenReady === 'function') {
|
|
initializePluginPageWhenReady();
|
|
} else if (typeof window.initPluginsPage === 'function') {
|
|
window.initPluginsPage();
|
|
}
|
|
} else {
|
|
console.log('installed-plugins-grid not found yet, will retry via event listeners');
|
|
}
|
|
}, 200);
|
|
|