diff --git a/CHANGELOG.md b/CHANGELOG.md index 4719b3b42..596ed3146 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,13 @@ All notable changes to Bambuddy will be documented in this file. ## [0.1.6-final] - Not released ### New Features +- **Home Assistant Script Support** - Control multiple devices together using HA scripts (Issue #176): + - Add HA script entities (e.g., `script.turn_on_printer_setup`) as smart plugs + - Trigger scripts to control printer + enclosure fan + other devices simultaneously + - Scripts show "Run" button instead of On/Off (scripts execute once when triggered) + - Script automation: Run scripts automatically when main printer plug turns on/off + - Show/hide scripts on printer cards (configurable per script) + - Scripts appear in dedicated row on printer cards with quick-run buttons - **STL Thumbnail Generation** - Auto-generate preview thumbnails for STL files (Issue #156): - Checkbox option when uploading STL files to generate thumbnails automatically - Batch generate thumbnails for existing STL files via "Generate Thumbnails" button @@ -123,6 +130,15 @@ All notable changes to Bambuddy will be documented in this file. - Job Failed: When a job fails to start (enabled by default) - Queue Complete: When all queued jobs finish - New "Print Queue" section in notification provider settings +- **Installation Scripts** - Interactive install scripts for Linux and macOS: + - Native install (`install.sh`): Python venv, systemd/launchd service, Node.js 22 + - Docker install (`docker-install.sh`): Docker Compose setup with health checks + - Interactive prompts for: install path, port, bind address, timezone, data/log directories + - Unattended mode with `--yes` flag for automation + - Auto-detects OS and package manager (apt, dnf, pacman, brew) + - Option to set system timezone during installation + - Shows IP address for network access when binding to 0.0.0.0 + - Supports updating existing installations ### Fixes - **Multi-Plate Thumbnail in Queue** - Fixed queue items showing wrong thumbnail for multi-plate files (Issue #166): @@ -164,6 +180,10 @@ All notable changes to Bambuddy will be documented in this file. - Tooltip shows full name on hover - **K-Profiles Backup Status** - Fixed GitHub backup settings showing incorrect printer connection count (e.g., "1/2 connected" when both printers are connected); now fetches status from API instead of relying on WebSocket cache - **GitHub Backup Timestamps** - Removed volatile timestamps from GitHub backup files so git diffs only show actual data changes +- **Home Assistant Smart Plug Auto-On** - Fixed print scheduler only checking Tasmota plugs when powering on printers for queued prints (Issue #200): + - Home Assistant smart plugs now work with automatic printer power-on for scheduled queue jobs + - Previously only Tasmota plugs were checked, causing HA plugs to fail with "Tasmota device not found" error + - Both `_power_on_and_wait` and `_power_off_if_needed` now use the correct service based on plug type ### Maintenance - Upgraded vitest from 2.x to 3.x to resolve npm audit security vulnerabilities in dev dependencies diff --git a/backend/app/api/routes/settings.py b/backend/app/api/routes/settings.py index b687d3ff9..67b4cd89e 100644 --- a/backend/app/api/routes/settings.py +++ b/backend/app/api/routes/settings.py @@ -383,6 +383,7 @@ async def export_backup( "schedule_on_time": plug.schedule_on_time, "schedule_off_time": plug.schedule_off_time, "show_in_switchbar": plug.show_in_switchbar, + "show_on_printer_card": plug.show_on_printer_card, } ) backup["included"].append("smart_plugs") @@ -1316,6 +1317,7 @@ async def import_backup( existing.schedule_on_time = plug_data.get("schedule_on_time") existing.schedule_off_time = plug_data.get("schedule_off_time") existing.show_in_switchbar = plug_data.get("show_in_switchbar", False) + existing.show_on_printer_card = plug_data.get("show_on_printer_card", True) restored["smart_plugs"] += 1 else: skipped["smart_plugs"] += 1 @@ -1345,6 +1347,7 @@ async def import_backup( schedule_on_time=plug_data.get("schedule_on_time"), schedule_off_time=plug_data.get("schedule_off_time"), show_in_switchbar=plug_data.get("show_in_switchbar", False), + show_on_printer_card=plug_data.get("show_on_printer_card", True), ) db.add(plug) restored["smart_plugs"] += 1 diff --git a/backend/app/api/routes/smart_plugs.py b/backend/app/api/routes/smart_plugs.py index 1a73338cf..29aec241f 100644 --- a/backend/app/api/routes/smart_plugs.py +++ b/backend/app/api/routes/smart_plugs.py @@ -56,9 +56,21 @@ async def create_smart_plug( raise HTTPException(400, "Printer not found") # Check if printer already has a plug assigned - result = await db.execute(select(SmartPlug).where(SmartPlug.printer_id == data.printer_id)) - if result.scalar_one_or_none(): - raise HTTPException(400, "This printer already has a smart plug assigned") + # Scripts can coexist with other plugs (they're for multi-device control, not power on/off) + is_script = data.plug_type == "homeassistant" and data.ha_entity_id and data.ha_entity_id.startswith("script.") + if not is_script: + # For non-script plugs, check there's no other non-script plug assigned + result = await db.execute(select(SmartPlug).where(SmartPlug.printer_id == data.printer_id)) + existing = result.scalar_one_or_none() + if existing: + # Allow if existing plug is a script + existing_is_script = ( + existing.plug_type == "homeassistant" + and existing.ha_entity_id + and existing.ha_entity_id.startswith("script.") + ) + if not existing_is_script: + raise HTTPException(400, "This printer already has a smart plug assigned") plug = SmartPlug(**data.model_dump()) db.add(plug) @@ -74,12 +86,48 @@ async def create_smart_plug( @router.get("/by-printer/{printer_id}", response_model=SmartPlugResponse | None) async def get_smart_plug_by_printer(printer_id: int, db: AsyncSession = Depends(get_db)): - """Get the smart plug assigned to a printer.""" + """Get the main smart plug assigned to a printer. + + When multiple plugs are assigned (e.g., a regular plug + script), + returns the main (non-script) plug for power control. + """ result = await db.execute(select(SmartPlug).where(SmartPlug.printer_id == printer_id)) - plug = result.scalar_one_or_none() - if not plug: + plugs = result.scalars().all() + + if not plugs: return None - return plug + + # If multiple plugs, prefer the non-script one (main power plug) + for plug in plugs: + is_script = plug.plug_type == "homeassistant" and plug.ha_entity_id and plug.ha_entity_id.startswith("script.") + if not is_script: + return plug + + # All are scripts, return the first one + return plugs[0] + + +@router.get("/by-printer/{printer_id}/scripts", response_model=list[SmartPlugResponse]) +async def get_script_plugs_by_printer(printer_id: int, db: AsyncSession = Depends(get_db)): + """Get all HA script plugs assigned to a printer. + + Returns only script entities (script.*) for the printer that have + show_on_printer_card enabled. + Used to display "Run Script" buttons alongside the main power plug. + """ + result = await db.execute(select(SmartPlug).where(SmartPlug.printer_id == printer_id)) + plugs = result.scalars().all() + + # Filter to only scripts with show_on_printer_card enabled + scripts = [ + plug + for plug in plugs + if plug.plug_type == "homeassistant" + and plug.ha_entity_id + and plug.ha_entity_id.startswith("script.") + and plug.show_on_printer_card + ] + return scripts # Tasmota Discovery Endpoints @@ -287,14 +335,29 @@ async def update_smart_plug( raise HTTPException(400, "Printer not found") # Check if that printer already has a different plug assigned - result = await db.execute( - select(SmartPlug).where( - SmartPlug.printer_id == new_printer_id, - SmartPlug.id != plug_id, + # Scripts can coexist with other plugs + # Determine if the plug being updated is/will be a script + new_entity_id = update_data.get("ha_entity_id", plug.ha_entity_id) + new_plug_type = update_data.get("plug_type", plug.plug_type) + is_script = new_plug_type == "homeassistant" and new_entity_id and new_entity_id.startswith("script.") + + if not is_script: + result = await db.execute( + select(SmartPlug).where( + SmartPlug.printer_id == new_printer_id, + SmartPlug.id != plug_id, + ) ) - ) - if result.scalar_one_or_none(): - raise HTTPException(400, "This printer already has a smart plug assigned") + existing = result.scalar_one_or_none() + if existing: + # Allow if existing plug is a script + existing_is_script = ( + existing.plug_type == "homeassistant" + and existing.ha_entity_id + and existing.ha_entity_id.startswith("script.") + ) + if not existing_is_script: + raise HTTPException(400, "This printer already has a smart plug assigned") for field, value in update_data.items(): setattr(plug, field, value) @@ -376,6 +439,13 @@ async def control_smart_plug( plug.last_checked = datetime.utcnow() await db.commit() + # Trigger associated scripts if this is a main (non-script) plug + is_main_plug = not ( + plug.plug_type == "homeassistant" and plug.ha_entity_id and plug.ha_entity_id.startswith("script.") + ) + if is_main_plug and plug.printer_id and expected_state: + await trigger_associated_scripts(plug.printer_id, expected_state, db) + # MQTT relay - publish smart plug state change if expected_state: try: @@ -401,6 +471,37 @@ async def control_smart_plug( return {"success": True, "action": control.action} +async def trigger_associated_scripts(printer_id: int, plug_state: str, db: AsyncSession): + """Trigger scripts linked to a printer based on main plug state change. + + When the main plug turns ON, triggers scripts with auto_on=True. + When the main plug turns OFF, triggers scripts with auto_off=True. + """ + result = await db.execute(select(SmartPlug).where(SmartPlug.printer_id == printer_id)) + plugs = result.scalars().all() + + # Find scripts that should be triggered + for plug in plugs: + is_script = plug.plug_type == "homeassistant" and plug.ha_entity_id and plug.ha_entity_id.startswith("script.") + if not is_script: + continue + + should_trigger = False + if plug_state == "ON" and plug.auto_on: + should_trigger = True + logger.info(f"Auto-triggering script '{plug.name}' on printer power-on") + elif plug_state == "OFF" and plug.auto_off: + should_trigger = True + logger.info(f"Auto-triggering script '{plug.name}' on printer power-off") + + if should_trigger: + try: + service = await _get_service_for_plug(plug, db) + await service.turn_on(plug) # Scripts are triggered by calling turn_on + except Exception as e: + logger.error(f"Failed to trigger script '{plug.name}': {e}") + + @router.get("/{plug_id}/status", response_model=SmartPlugStatus) async def get_plug_status(plug_id: int, db: AsyncSession = Depends(get_db)): """Get current plug status from device including energy data.""" diff --git a/backend/app/core/database.py b/backend/app/core/database.py index 5b3175ff4..0bd28ed08 100644 --- a/backend/app/core/database.py +++ b/backend/app/core/database.py @@ -760,6 +760,79 @@ async def run_migrations(conn): except Exception: pass + # Migration: Remove UNIQUE constraint from smart_plugs.printer_id + # This allows HA scripts to coexist with regular plugs (scripts are for multi-device control) + # SQLite requires table recreation to drop constraints + try: + # Check if we need to migrate (if UNIQUE constraint exists) + result = await conn.execute(text("SELECT sql FROM sqlite_master WHERE type='table' AND name='smart_plugs'")) + row = result.fetchone() + if row and "printer_id INTEGER UNIQUE" in (row[0] or ""): + # Create new table without UNIQUE constraint on printer_id + await conn.execute( + text(""" + CREATE TABLE smart_plugs_temp ( + id INTEGER PRIMARY KEY, + name VARCHAR(100) NOT NULL, + ip_address VARCHAR(45), + plug_type VARCHAR(20) DEFAULT 'tasmota', + ha_entity_id VARCHAR(100), + ha_power_entity VARCHAR(100), + ha_energy_today_entity VARCHAR(100), + ha_energy_total_entity VARCHAR(100), + printer_id INTEGER REFERENCES printers(id) ON DELETE SET NULL, + enabled BOOLEAN NOT NULL DEFAULT 1, + auto_on BOOLEAN NOT NULL DEFAULT 1, + auto_off BOOLEAN NOT NULL DEFAULT 1, + off_delay_mode VARCHAR(20) NOT NULL DEFAULT 'time', + off_delay_minutes INTEGER NOT NULL DEFAULT 5, + off_temp_threshold INTEGER NOT NULL DEFAULT 70, + username VARCHAR(50), + password VARCHAR(100), + power_alert_enabled BOOLEAN NOT NULL DEFAULT 0, + power_alert_high FLOAT, + power_alert_low FLOAT, + power_alert_last_triggered DATETIME, + schedule_enabled BOOLEAN NOT NULL DEFAULT 0, + schedule_on_time VARCHAR(5), + schedule_off_time VARCHAR(5), + show_in_switchbar BOOLEAN DEFAULT 0, + last_state VARCHAR(10), + last_checked DATETIME, + auto_off_executed BOOLEAN NOT NULL DEFAULT 0, + auto_off_pending BOOLEAN DEFAULT 0, + auto_off_pending_since DATETIME, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL + ) + """) + ) + # Copy data + await conn.execute( + text(""" + INSERT INTO smart_plugs_temp + SELECT id, name, ip_address, plug_type, ha_entity_id, ha_power_entity, + ha_energy_today_entity, ha_energy_total_entity, printer_id, enabled, + auto_on, auto_off, off_delay_mode, off_delay_minutes, off_temp_threshold, + username, password, power_alert_enabled, power_alert_high, power_alert_low, + power_alert_last_triggered, schedule_enabled, schedule_on_time, schedule_off_time, + show_in_switchbar, last_state, last_checked, auto_off_executed, + auto_off_pending, auto_off_pending_since, created_at, updated_at + FROM smart_plugs + """) + ) + # Drop old table and rename new one + await conn.execute(text("DROP TABLE smart_plugs")) + await conn.execute(text("ALTER TABLE smart_plugs_temp RENAME TO smart_plugs")) + except Exception: + pass + + # Migration: Add show_on_printer_card column to smart_plugs + try: + await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN show_on_printer_card BOOLEAN DEFAULT 1")) + except Exception: + pass + # Migration: Add sliced_for_model column to print_archives for printer model from 3MF try: await conn.execute(text("ALTER TABLE print_archives ADD COLUMN sliced_for_model VARCHAR(50)")) diff --git a/backend/app/models/smart_plug.py b/backend/app/models/smart_plug.py index 2f13ebc38..8f0ff1650 100644 --- a/backend/app/models/smart_plug.py +++ b/backend/app/models/smart_plug.py @@ -24,10 +24,8 @@ class SmartPlug(Base): ha_energy_today_entity: Mapped[str | None] = mapped_column(String(100), nullable=True) # sensor.xxx_today ha_energy_total_entity: Mapped[str | None] = mapped_column(String(100), nullable=True) # sensor.xxx_total - # Link to printer (1:1) - printer_id: Mapped[int | None] = mapped_column( - ForeignKey("printers.id", ondelete="SET NULL"), unique=True, nullable=True - ) + # Link to printer (scripts can coexist with regular plugs for multi-device control) + printer_id: Mapped[int | None] = mapped_column(ForeignKey("printers.id", ondelete="SET NULL"), nullable=True) # Automation settings enabled: Mapped[bool] = mapped_column(Boolean, default=True) @@ -54,8 +52,9 @@ class SmartPlug(Base): schedule_on_time: Mapped[str | None] = mapped_column(String(5), nullable=True) # "HH:MM" format schedule_off_time: Mapped[str | None] = mapped_column(String(5), nullable=True) # "HH:MM" format - # Switchbar visibility + # Visibility options show_in_switchbar: Mapped[bool] = mapped_column(Boolean, default=False) + show_on_printer_card: Mapped[bool] = mapped_column(Boolean, default=True) # For scripts: show on printer card # Status tracking last_state: Mapped[str | None] = mapped_column(String(10), nullable=True) # "ON"/"OFF" diff --git a/backend/app/schemas/smart_plug.py b/backend/app/schemas/smart_plug.py index d60fac854..ca9816228 100644 --- a/backend/app/schemas/smart_plug.py +++ b/backend/app/schemas/smart_plug.py @@ -14,7 +14,7 @@ class SmartPlugBase(BaseModel): password: str | None = None # Home Assistant fields (required when plug_type="homeassistant") - ha_entity_id: str | None = Field(default=None, pattern=r"^(switch|light|input_boolean)\.[a-z0-9_]+$") + ha_entity_id: str | None = Field(default=None, pattern=r"^(switch|light|input_boolean|script)\.[a-z0-9_]+$") # Home Assistant energy sensor entities (optional, for separate energy sensors) ha_power_entity: str | None = Field(default=None, pattern=r"^sensor\.[a-z0-9_]+$") ha_energy_today_entity: str | None = Field(default=None, pattern=r"^sensor\.[a-z0-9_]+$") @@ -35,8 +35,9 @@ class SmartPlugBase(BaseModel): schedule_enabled: bool = False schedule_on_time: str | None = Field(default=None, pattern=r"^([01]\d|2[0-3]):[0-5]\d$") # HH:MM format schedule_off_time: str | None = Field(default=None, pattern=r"^([01]\d|2[0-3]):[0-5]\d$") # HH:MM format - # Switchbar visibility + # Visibility options show_in_switchbar: bool = False + show_on_printer_card: bool = True # For scripts: show on printer card @model_validator(mode="after") def validate_plug_type_fields(self) -> "SmartPlugBase": @@ -77,8 +78,9 @@ class SmartPlugUpdate(BaseModel): schedule_enabled: bool | None = None schedule_on_time: str | None = Field(default=None, pattern=r"^([01]\d|2[0-3]):[0-5]\d$") schedule_off_time: str | None = Field(default=None, pattern=r"^([01]\d|2[0-3]):[0-5]\d$") - # Switchbar visibility + # Visibility options show_in_switchbar: bool | None = None + show_on_printer_card: bool | None = None class SmartPlugResponse(SmartPlugBase): @@ -147,7 +149,7 @@ class HAEntity(BaseModel): entity_id: str friendly_name: str state: str | None = None - domain: str # "switch", "light", "input_boolean" + domain: str # "switch", "light", "input_boolean", "script" class HASensorEntity(BaseModel): diff --git a/backend/app/services/homeassistant.py b/backend/app/services/homeassistant.py index 38fca794b..45ba70920 100644 --- a/backend/app/services/homeassistant.py +++ b/backend/app/services/homeassistant.py @@ -228,7 +228,7 @@ class HomeAssistantService: - domain: str """ # Default domains for smart plug control - default_domains = {"switch", "light", "input_boolean"} + default_domains = {"switch", "light", "input_boolean", "script"} try: async with httpx.AsyncClient(timeout=self.timeout) as client: diff --git a/backend/app/services/print_scheduler.py b/backend/app/services/print_scheduler.py index 81ab1e520..d96d09883 100644 --- a/backend/app/services/print_scheduler.py +++ b/backend/app/services/print_scheduler.py @@ -17,7 +17,7 @@ from backend.app.models.smart_plug import SmartPlug from backend.app.services.bambu_ftp import delete_file_async, get_ftp_retry_settings, upload_file_async, with_ftp_retry from backend.app.services.notification_service import notification_service from backend.app.services.printer_manager import printer_manager -from backend.app.services.tasmota import tasmota_service +from backend.app.services.smart_plug_manager import smart_plug_manager from backend.app.utils.printer_models import normalize_printer_model logger = logging.getLogger(__name__) @@ -348,15 +348,18 @@ class PrintScheduler: Returns True if printer connected successfully within timeout. """ + # Get the appropriate service for the plug type (Tasmota or Home Assistant) + service = await smart_plug_manager.get_service_for_plug(plug, db) + # Check current plug state - status = await tasmota_service.get_status(plug) + status = await service.get_status(plug) if not status.get("reachable"): logger.warning(f"Smart plug '{plug.name}' is not reachable") return False # Turn on if not already on if status.get("state") != "ON": - success = await tasmota_service.turn_on(plug) + success = await service.turn_on(plug) if not success: logger.warning(f"Failed to turn on smart plug '{plug.name}'") return False @@ -425,7 +428,8 @@ class PrintScheduler: # Wait for cooldown (up to 10 minutes) await printer_manager.wait_for_cooldown(item.printer_id, target_temp=50.0, timeout=600) logger.info(f"Auto-off: Powering off printer {item.printer_id}") - await tasmota_service.turn_off(plug) + service = await smart_plug_manager.get_service_for_plug(plug, db) + await service.turn_off(plug) async def _get_job_name(self, db: AsyncSession, item: PrintQueueItem) -> str: """Get a human-readable name for a queue item.""" diff --git a/backend/app/services/smart_plug_manager.py b/backend/app/services/smart_plug_manager.py index 39366d7da..e788068a9 100644 --- a/backend/app/services/smart_plug_manager.py +++ b/backend/app/services/smart_plug_manager.py @@ -27,7 +27,7 @@ class SmartPlugManager: self._scheduler_task: asyncio.Task | None = None self._last_schedule_check: dict[int, str] = {} # plug_id -> "HH:MM" last executed - async def _get_service_for_plug(self, plug: "SmartPlug", db: AsyncSession | None = None): + async def get_service_for_plug(self, plug: "SmartPlug", db: AsyncSession | None = None): """Get the appropriate service for the plug type. For HA plugs, configures the service with current settings from DB. @@ -110,7 +110,7 @@ class SmartPlugManager: plugs = result.scalars().all() for plug in plugs: - service = await self._get_service_for_plug(plug, db) + service = await self.get_service_for_plug(plug, db) # Check if we should turn on if plug.schedule_on_time == current_time: @@ -166,7 +166,7 @@ class SmartPlugManager: # Turn on the plug logger.info(f"Print started on printer {printer_id}, turning on plug '{plug.name}'") - service = await self._get_service_for_plug(plug, db) + service = await self.get_service_for_plug(plug, db) success = await service.turn_on(plug) if success: @@ -195,6 +195,11 @@ class SmartPlugManager: logger.debug(f"Smart plug '{plug.name}' auto_off is disabled") return + # Skip auto-off for HA script entities (scripts can only be triggered, not turned off) + if plug.plug_type == "homeassistant" and plug.ha_entity_id and plug.ha_entity_id.startswith("script."): + logger.debug(f"Smart plug '{plug.name}' is a HA script entity, skipping auto-off") + return + # Only auto-off on successful completion, not on failures # This allows the user to investigate errors before power-off if status != "completed": @@ -261,7 +266,7 @@ class SmartPlugManager: self.name = f"plug_{plug_id}" plug_info = PlugInfo() - service = await self._get_service_for_plug(plug_info) + service = await self.get_service_for_plug(plug_info) success = await service.turn_off(plug_info) logger.info(f"Turned off plug {plug_id} after time delay") @@ -353,7 +358,7 @@ class SmartPlugManager: self.name = f"plug_{plug_id}" plug_info = PlugInfo() - service = await self._get_service_for_plug(plug_info) + service = await self.get_service_for_plug(plug_info) success = await service.turn_off(plug_info) logger.info( f"Turned off plug {plug_id} after nozzle temp dropped to " @@ -474,7 +479,7 @@ class SmartPlugManager: # For time mode, just turn off immediately since delay already passed logger.info(f"Time-based auto-off was pending, turning off plug '{plug.name}' now") - service = await self._get_service_for_plug(plug, db) + service = await self.get_service_for_plug(plug, db) success = await service.turn_off(plug) if success: await self._mark_auto_off_executed(plug.id) diff --git a/backend/tests/integration/test_smart_plugs_api.py b/backend/tests/integration/test_smart_plugs_api.py index e6ad7b573..8c47c2b5f 100644 --- a/backend/tests/integration/test_smart_plugs_api.py +++ b/backend/tests/integration/test_smart_plugs_api.py @@ -573,3 +573,200 @@ class TestSmartPlugsAPI: assert response.status_code == 400 assert "not configured" in response.json()["detail"].lower() + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_create_homeassistant_script_plug(self, async_client: AsyncClient): + """Verify Home Assistant script entity can be created as a plug. + + Scripts allow users to trigger HA automations that control multiple devices + (e.g., turn on printer + fan together). Scripts can only be triggered (turn_on), + not turned off. + """ + data = { + "name": "Turn On Printer Setup", + "plug_type": "homeassistant", + "ha_entity_id": "script.turn_on_printer_and_fan", + "enabled": True, + "auto_on": True, + "auto_off": False, # Scripts don't support auto_off + } + + response = await async_client.post("/api/v1/smart-plugs/", json=data) + + assert response.status_code == 200 + result = response.json() + assert result["name"] == "Turn On Printer Setup" + assert result["plug_type"] == "homeassistant" + assert result["ha_entity_id"] == "script.turn_on_printer_and_fan" + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_control_homeassistant_script( + self, async_client: AsyncClient, smart_plug_factory, mock_homeassistant_service, db_session + ): + """Verify HA script entity can be triggered via control endpoint.""" + plug = await smart_plug_factory(plug_type="homeassistant", ha_entity_id="script.turn_on_printer") + + # Scripts use "on" action to trigger + response = await async_client.post(f"/api/v1/smart-plugs/{plug.id}/control", json={"action": "on"}) + + assert response.status_code == 200 + result = response.json() + assert result["success"] is True + assert result["action"] == "on" + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_invalid_ha_entity_domain(self, async_client: AsyncClient): + """Verify invalid HA entity domains are rejected.""" + data = { + "name": "Invalid Entity", + "plug_type": "homeassistant", + "ha_entity_id": "sensor.some_sensor", # sensor domain not allowed + "enabled": True, + } + + response = await async_client.post("/api/v1/smart-plugs/", json=data) + + assert response.status_code == 422 # Validation error + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_script_can_coexist_with_regular_plug( + self, async_client: AsyncClient, smart_plug_factory, printer_factory, db_session + ): + """Verify HA scripts can be assigned to printers that already have a regular plug. + + Scripts are for multi-device control (e.g., turn on printer + fan together), + so they should coexist with the main power plug. + """ + # Create a printer + printer = await printer_factory(name="Test Printer") + + # Create a regular Tasmota plug assigned to the printer + main_plug = await smart_plug_factory( + name="Main Power Plug", + plug_type="tasmota", + ip_address="192.168.1.100", + printer_id=printer.id, + ) + assert main_plug.printer_id == printer.id + + # Now try to create a script also assigned to the same printer + script_data = { + "name": "Turn On Everything", + "plug_type": "homeassistant", + "ha_entity_id": "script.turn_on_printer_setup", + "printer_id": printer.id, + "enabled": True, + } + + response = await async_client.post("/api/v1/smart-plugs/", json=script_data) + + # Should succeed - scripts can coexist with regular plugs + assert response.status_code == 200 + result = response.json() + assert result["printer_id"] == printer.id + assert result["ha_entity_id"] == "script.turn_on_printer_setup" + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_regular_plug_blocked_when_another_exists( + self, async_client: AsyncClient, smart_plug_factory, printer_factory, db_session + ): + """Verify regular plugs cannot be assigned if printer already has one.""" + # Create a printer + printer = await printer_factory(name="Test Printer") + + # Create a regular plug assigned to the printer + await smart_plug_factory( + name="Main Power Plug", + plug_type="tasmota", + ip_address="192.168.1.100", + printer_id=printer.id, + ) + + # Try to create another regular plug for the same printer + another_plug = { + "name": "Second Plug", + "plug_type": "homeassistant", + "ha_entity_id": "switch.another_plug", + "printer_id": printer.id, + "enabled": True, + } + + response = await async_client.post("/api/v1/smart-plugs/", json=another_plug) + + # Should fail - only one regular plug per printer + assert response.status_code == 400 + assert "already has a smart plug" in response.json()["detail"] + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_get_scripts_by_printer_filters_by_show_on_printer_card( + self, async_client: AsyncClient, smart_plug_factory, printer_factory, db_session + ): + """Verify scripts endpoint only returns scripts with show_on_printer_card=True.""" + printer = await printer_factory(name="Test Printer") + + # Create a script with show_on_printer_card=True (default) + visible_script = await smart_plug_factory( + name="Visible Script", + plug_type="homeassistant", + ha_entity_id="script.visible_script", + printer_id=printer.id, + show_on_printer_card=True, + ) + + # Create a script with show_on_printer_card=False + await smart_plug_factory( + name="Hidden Script", + plug_type="homeassistant", + ha_entity_id="script.hidden_script", + printer_id=printer.id, + show_on_printer_card=False, + ) + + response = await async_client.get(f"/api/v1/smart-plugs/by-printer/{printer.id}/scripts") + + assert response.status_code == 200 + scripts = response.json() + # Should only return the visible script + assert len(scripts) == 1 + assert scripts[0]["id"] == visible_script.id + assert scripts[0]["name"] == "Visible Script" + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_script_auto_on_auto_off_fields( + self, async_client: AsyncClient, smart_plug_factory, printer_factory, db_session + ): + """Verify scripts can have auto_on and auto_off set for automation triggers.""" + printer = await printer_factory(name="Test Printer") + + # Create a script with custom auto_on/auto_off settings + script_data = { + "name": "Fan Control Script", + "plug_type": "homeassistant", + "ha_entity_id": "script.fan_control", + "printer_id": printer.id, + "auto_on": True, + "auto_off": False, + "show_on_printer_card": True, + } + + response = await async_client.post("/api/v1/smart-plugs/", json=script_data) + + assert response.status_code == 200 + result = response.json() + assert result["auto_on"] is True + assert result["auto_off"] is False + assert result["show_on_printer_card"] is True + + # Update the script's auto_off setting + update_response = await async_client.patch(f"/api/v1/smart-plugs/{result['id']}", json={"auto_off": True}) + + assert update_response.status_code == 200 + updated = update_response.json() + assert updated["auto_off"] is True diff --git a/deploy/bambuddy.service b/deploy/bambuddy.service index b096b3d29..93c779466 100644 --- a/deploy/bambuddy.service +++ b/deploy/bambuddy.service @@ -1,31 +1,61 @@ +# BamBuddy Systemd Service Template +# +# INSTALLATION: +# 1. Copy this file to /etc/systemd/system/bambuddy.service +# 2. Replace placeholders: +# - INSTALL_PATH: Where BamBuddy is installed (e.g., /opt/bambuddy) +# - SERVICE_USER: User to run as (e.g., bambuddy) +# - DATA_DIR: Data directory (e.g., /opt/bambuddy/data) +# - LOG_DIR: Log directory (e.g., /opt/bambuddy/logs) +# 3. Run: sudo systemctl daemon-reload +# 4. Run: sudo systemctl enable bambuddy +# 5. Run: sudo systemctl start bambuddy +# +# Or use the install script: ./install/install.sh +# + [Unit] -Description=BamBuddy Print Archive +Description=BamBuddy - Bambu Lab Print Management +Documentation=https://github.com/maziggy/bambuddy After=network.target [Service] Type=simple -User=claude -Group=claude -WorkingDirectory=/bambuddy -Environment="PATH=/bambuddy/logs -ExecStartPre=+/bin/mkdir -p /bambuddy/archive -ExecStartPre=+/bin/chown -R : /bambuddy/logs -ExecStartPre=+/bin/chown -R : /bambuddy/archive +# Logging +StandardOutput=journal +StandardError=journal +SyslogIdentifier=bambuddy -ExecStart=/bambuddy/venv/bin/uvicorn backend.app.main:app --host 0.0.0.0 --port 8000 -Restart=always -RestartSec=10 +# Security hardening +NoNewPrivileges=true +PrivateTmp=true +ProtectSystem=strict +ProtectHome=true +ReadWritePaths=DATA_DIR LOG_DIR INSTALL_PATH [Install] WantedBy=multi-user.target diff --git a/frontend/src/__tests__/pages/FileManagerPage.test.tsx b/frontend/src/__tests__/pages/FileManagerPage.test.tsx index 32d3d7aba..b6cb6f7ae 100644 --- a/frontend/src/__tests__/pages/FileManagerPage.test.tsx +++ b/frontend/src/__tests__/pages/FileManagerPage.test.tsx @@ -347,8 +347,8 @@ describe('FileManagerPage', () => { render(); await waitFor(() => { - // Sort dropdown should show Date as default option - expect(screen.getByDisplayValue('Date')).toBeInTheDocument(); + // Sort dropdown should show Name as default option (persisted to localStorage) + expect(screen.getByDisplayValue('Name')).toBeInTheDocument(); }); }); }); diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 4b4046a01..80a95fc60 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -848,7 +848,7 @@ export interface SmartPlug { name: string; plug_type: 'tasmota' | 'homeassistant'; ip_address: string | null; // Required for Tasmota - ha_entity_id: string | null; // Required for Home Assistant (e.g., "switch.printer_plug") + ha_entity_id: string | null; // Required for Home Assistant (e.g., "switch.printer_plug", "script.turn_on_printer") // Home Assistant energy sensor entities (optional) ha_power_entity: string | null; ha_energy_today_entity: string | null; @@ -871,8 +871,9 @@ export interface SmartPlug { schedule_enabled: boolean; schedule_on_time: string | null; schedule_off_time: string | null; - // Switchbar visibility + // Visibility options show_in_switchbar: boolean; + show_on_printer_card: boolean; // For scripts: show on printer card // Status last_state: string | null; last_checked: string | null; @@ -907,8 +908,9 @@ export interface SmartPlugCreate { schedule_enabled?: boolean; schedule_on_time?: string | null; schedule_off_time?: string | null; - // Switchbar visibility + // Visibility options show_in_switchbar?: boolean; + show_on_printer_card?: boolean; } export interface SmartPlugUpdate { @@ -937,8 +939,9 @@ export interface SmartPlugUpdate { schedule_enabled?: boolean; schedule_on_time?: string | null; schedule_off_time?: string | null; - // Switchbar visibility + // Visibility options show_in_switchbar?: boolean; + show_on_printer_card?: boolean; } // Home Assistant entity for smart plug selection @@ -946,7 +949,7 @@ export interface HAEntity { entity_id: string; friendly_name: string; state: string | null; - domain: string; // "switch", "light", "input_boolean" + domain: string; // "switch", "light", "input_boolean", "script" } // Home Assistant sensor entity for energy monitoring @@ -2440,6 +2443,7 @@ export const api = { getSmartPlugs: () => request('/smart-plugs/'), getSmartPlug: (id: number) => request(`/smart-plugs/${id}`), getSmartPlugByPrinter: (printerId: number) => request(`/smart-plugs/by-printer/${printerId}`), + getScriptPlugsByPrinter: (printerId: number) => request(`/smart-plugs/by-printer/${printerId}/scripts`), createSmartPlug: (data: SmartPlugCreate) => request('/smart-plugs/', { method: 'POST', diff --git a/frontend/src/components/AddSmartPlugModal.tsx b/frontend/src/components/AddSmartPlugModal.tsx index bba6c11d0..df4c760b6 100644 --- a/frontend/src/components/AddSmartPlugModal.tsx +++ b/frontend/src/components/AddSmartPlugModal.tsx @@ -1,6 +1,6 @@ import { useState, useEffect, useRef } from 'react'; import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query'; -import { X, Save, Loader2, Wifi, WifiOff, CheckCircle, Bell, Clock, LayoutGrid, Search, Plug, Power, Home } from 'lucide-react'; +import { X, Save, Loader2, Wifi, WifiOff, CheckCircle, Bell, Clock, LayoutGrid, Search, Plug, Power, Home, Play, Eye } from 'lucide-react'; import { api } from '../api/client'; import type { SmartPlug, SmartPlugCreate, SmartPlugUpdate, DiscoveredTasmotaDevice } from '../api/client'; import { Button } from './Button'; @@ -51,6 +51,10 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) { const [testResult, setTestResult] = useState<{ success: boolean; state?: string | null; device_name?: string | null } | null>(null); const [error, setError] = useState(null); + // Automation settings + const [autoOn, setAutoOn] = useState(plug?.auto_on ?? true); + const [autoOff, setAutoOff] = useState(plug?.auto_off ?? true); + // Power alert settings const [powerAlertEnabled, setPowerAlertEnabled] = useState(plug?.power_alert_enabled || false); const [powerAlertHigh, setPowerAlertHigh] = useState(plug?.power_alert_high?.toString() || ''); @@ -61,8 +65,9 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) { const [scheduleOnTime, setScheduleOnTime] = useState(plug?.schedule_on_time || ''); const [scheduleOffTime, setScheduleOffTime] = useState(plug?.schedule_off_time || ''); - // Switchbar visibility + // Visibility options const [showInSwitchbar, setShowInSwitchbar] = useState(plug?.show_in_switchbar || false); + const [showOnPrinterCard, setShowOnPrinterCard] = useState(plug?.show_on_printer_card ?? true); // Discovery state const [isScanning, setIsScanning] = useState(false); @@ -235,6 +240,10 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) { mutationFn: (data: SmartPlugCreate) => api.createSmartPlug(data), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['smart-plugs'] }); + // Also invalidate script plugs queries for printer cards + queryClient.invalidateQueries({ predicate: (query) => + Array.isArray(query.queryKey) && query.queryKey[0] === 'scriptPlugsByPrinter' + }); onClose(); }, onError: (err: Error) => { @@ -247,6 +256,10 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) { mutationFn: (data: SmartPlugUpdate) => api.updateSmartPlug(plug!.id, data), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['smart-plugs'] }); + // Also invalidate script plugs queries for printer cards + queryClient.invalidateQueries({ predicate: (query) => + Array.isArray(query.queryKey) && query.queryKey[0] === 'scriptPlugsByPrinter' + }); onClose(); }, onError: (err: Error) => { @@ -254,11 +267,17 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) { }, }); + // Check if selected entity is a script (scripts allow multiple plugs per printer) + const isScript = plugType === 'homeassistant' && haEntityId?.startsWith('script.'); + // Filter out printers that already have a plug assigned (except current plug's printer) - const availablePrinters = printers?.filter(p => { - const hasPlug = existingPlugs?.some(ep => ep.printer_id === p.id && ep.id !== plug?.id); - return !hasPlug; - }); + // Scripts can link to any printer (they're for multi-device control) + const availablePrinters = isScript + ? printers + : printers?.filter(p => { + const hasPlug = existingPlugs?.some(ep => ep.printer_id === p.id && ep.id !== plug?.id); + return !hasPlug; + }); const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); @@ -291,6 +310,9 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) { username: plugType === 'tasmota' ? (username.trim() || null) : null, password: plugType === 'tasmota' ? (password.trim() || null) : null, printer_id: printerId, + // Automation + auto_on: autoOn, + auto_off: autoOff, // Power alerts power_alert_enabled: powerAlertEnabled, power_alert_high: powerAlertHigh ? parseFloat(powerAlertHigh) : null, @@ -299,8 +321,9 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) { schedule_enabled: scheduleEnabled, schedule_on_time: scheduleOnTime || null, schedule_off_time: scheduleOffTime || null, - // Switchbar + // Visibility options show_in_switchbar: showInSwitchbar, + show_on_printer_card: showOnPrinterCard, }; if (isEditing) { @@ -582,15 +605,15 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {

{debouncedSearch ? `Searching all entities (${availableEntities.length} found)` - : `Showing switch, light, input_boolean (${availableEntities.length} available)`} + : `Showing switch, light, input_boolean, script (${availableEntities.length} available)`}

); })()} - {/* Energy Monitoring Section (Optional) */} - {haEntityId && haSensorEntities && haSensorEntities.length > 0 && ( + {/* Energy Monitoring Section (Optional) - hidden for scripts */} + {haEntityId && !isScript && haSensorEntities && haSensorEntities.length > 0 && (

Energy Monitoring (Optional)

@@ -975,110 +998,183 @@ export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) { ))}

- Linking enables automatic on/off when prints start/complete + {isScript + ? 'Link to a printer to enable auto-run triggers' + : 'Linking enables automatic on/off when prints start/complete'}

- {/* Power Alerts */} -
-
-
- - Power Alerts + {/* Script Options - only show for HA scripts */} + {isScript && ( +
+
+ + Script Automation +
+ + {/* Auto-run when printer turns on */} +
+
+ Run when printer turns on +

Execute script when main plug is switched on

+
+ +
+ + {/* Auto-run when printer turns off */} +
+
+ Run when printer turns off +

Execute script when main plug is switched off

+
+
-
- {powerAlertEnabled && ( -
-
-
- - setPowerAlertHigh(e.target.value)} - placeholder="e.g. 200" - min="0" - max="5000" - className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none" - /> + )} + + {/* Power Alerts - hidden for scripts (no power monitoring) */} + {!isScript && ( +
+
+
+ + Power Alerts +
+ +
+ {powerAlertEnabled && ( +
+
+
+ + setPowerAlertHigh(e.target.value)} + placeholder="e.g. 200" + min="0" + max="5000" + className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none" + /> +
+
+ + setPowerAlertLow(e.target.value)} + placeholder="e.g. 10" + min="0" + max="5000" + className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none" + /> +
+

+ Get notified when power consumption crosses these thresholds. Leave empty to disable that direction. +

+
+ )} +
+ )} + + {/* Schedule - hidden for scripts */} + {!isScript && ( +
+
+
+ + Daily Schedule +
+ +
+ {scheduleEnabled && ( +
+
+
+ + setScheduleOnTime(e.target.value)} + className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none" + /> +
+
+ + setScheduleOffTime(e.target.value)} + className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none" + /> +
+
+

+ Automatically turn the plug on/off at these times daily. Leave empty to skip that action. +

+
+ )} +
+ )} + + {/* Visibility Options */} +
+ {/* Show on printer card - only for scripts */} + {isScript && ( +
+
+
- - setPowerAlertLow(e.target.value)} - placeholder="e.g. 10" - min="0" - max="5000" - className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none" - /> + Show on Printer Card +

Display script button on printer card

-

- Get notified when power consumption crosses these thresholds. Leave empty to disable that direction. -

+
)} -
- {/* Schedule */} -
-
-
- - Daily Schedule -
- -
- {scheduleEnabled && ( -
-
-
- - setScheduleOnTime(e.target.value)} - className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none" - /> -
-
- - setScheduleOffTime(e.target.value)} - className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none" - /> -
-
-

- Automatically turn the plug on/off at these times daily. Leave empty to skip that action. -

-
- )} -
- - {/* Switchbar Visibility */} -
+ {/* Show in Switchbar */}
diff --git a/frontend/src/components/SmartPlugCard.tsx b/frontend/src/components/SmartPlugCard.tsx index ed1d4c01c..8fcc64130 100644 --- a/frontend/src/components/SmartPlugCard.tsx +++ b/frontend/src/components/SmartPlugCard.tsx @@ -1,6 +1,6 @@ import { useState } from 'react'; import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query'; -import { Plug, Power, PowerOff, Loader2, Trash2, Settings2, Thermometer, Clock, Wifi, WifiOff, Edit2, Bell, Calendar, LayoutGrid, ExternalLink, Home } from 'lucide-react'; +import { Plug, Power, PowerOff, Loader2, Trash2, Settings2, Thermometer, Clock, Wifi, WifiOff, Edit2, Bell, Calendar, LayoutGrid, ExternalLink, Home, Play, Eye } from 'lucide-react'; import { api } from '../api/client'; import type { SmartPlug, SmartPlugUpdate } from '../api/client'; import { Card, CardContent } from './Card'; @@ -55,12 +55,24 @@ export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) { return { previousStatus }; }, + onSuccess: (_data, action) => { + // Show toast for script triggers + const isScriptPlug = plug.plug_type === 'homeassistant' && plug.ha_entity_id?.startsWith('script.'); + if (isScriptPlug && action === 'on') { + showToast(`Script "${plug.name}" triggered`, 'success'); + } + }, onError: (_err, action, context) => { // Rollback on error if (context?.previousStatus) { queryClient.setQueryData(['smart-plug-status', plug.id], context.previousStatus); } - showToast(`Failed to turn ${action} "${plug.name}"`, 'error'); + const isScriptPlug = plug.plug_type === 'homeassistant' && plug.ha_entity_id?.startsWith('script.'); + if (isScriptPlug) { + showToast(`Failed to trigger script "${plug.name}"`, 'error'); + } else { + showToast(`Failed to turn ${action} "${plug.name}"`, 'error'); + } }, onSettled: () => { // Refetch after a short delay to get actual state @@ -80,6 +92,10 @@ export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) { if (plug.printer_id) { queryClient.invalidateQueries({ queryKey: ['smartPlugByPrinter', plug.printer_id] }); } + // Invalidate script plugs queries for printer cards + queryClient.invalidateQueries({ predicate: (query) => + Array.isArray(query.queryKey) && query.queryKey[0] === 'scriptPlugsByPrinter' + }); }, }); @@ -88,6 +104,10 @@ export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) { mutationFn: () => api.deleteSmartPlug(plug.id), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['smart-plugs'] }); + // Also invalidate script plugs queries for printer cards + queryClient.invalidateQueries({ predicate: (query) => + Array.isArray(query.queryKey) && query.queryKey[0] === 'scriptPlugsByPrinter' + }); }, }); @@ -95,6 +115,9 @@ export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) { const isReachable = status?.reachable ?? false; const isPending = controlMutation.isPending; + // Check if this is a HA script entity (scripts can only be triggered, not toggled) + const isScript = plug.plug_type === 'homeassistant' && plug.ha_entity_id?.startsWith('script.'); + // Generate admin URL with auto-login credentials (Tasmota only) const getAdminUrl = () => { if (plug.plug_type !== 'tasmota' || !plug.ip_address) return null; @@ -113,10 +136,12 @@ export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) { {/* Header Row */} -
+
-
- {plug.plug_type === 'homeassistant' ? ( +
+ {isScript ? ( + + ) : plug.plug_type === 'homeassistant' ? ( ) : ( @@ -124,20 +149,33 @@ export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) {

{plug.name}

-

+

{plug.plug_type === 'homeassistant' ? plug.ha_entity_id : plug.ip_address}

{/* Status indicator */} -
+
{statusLoading ? ( + ) : isScript ? ( + /* Script entities: show badge and Ready status stacked */ +
+ + + Script + + + {isReachable ? 'Ready' : 'Offline'} + +
) : isReachable ? (
- {status?.state || 'Unknown'} + + {status?.state || 'Unknown'} +
) : (
@@ -193,26 +231,43 @@ export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) { {/* Quick Controls */}
- - + {isScript ? ( + /* Script entities: single "Run" button */ + + ) : ( + /* Regular entities: On/Off buttons */ + <> + + + + )}
{/* Toggle Settings Panel */} @@ -230,6 +285,28 @@ export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) { {/* Expanded Settings */} {isExpanded && (
+ {/* Show on Printer Card Toggle - only for scripts */} + {isScript && ( +
+
+ +
+

Show on Printer Card

+

Display script button on printer card

+
+
+ +
+ )} + {/* Show in Switchbar Toggle */}
@@ -267,11 +344,13 @@ export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) {
- {/* Auto On */} + {/* Auto On / Run when printer turns on */}
-

Auto On

-

Turn on when print starts

+

{isScript ? 'Run when printer turns on' : 'Auto On'}

+

+ {isScript ? 'Execute script when main plug is switched on' : 'Turn on when print starts'} +

- {/* Auto Off */} + {/* Auto Off / Run when printer turns off */}
-

Auto Off

-

Turn off when print completes (one-shot)

+

{isScript ? 'Run when printer turns off' : 'Auto Off'}

+

+ {isScript ? 'Execute script when main plug is switched off' : 'Turn off when print completes (one-shot)'} +

- {/* Delay Mode */} - {plug.auto_off && ( + {/* Delay Mode - hidden for script entities */} + {plug.auto_off && !isScript && (

Turn Off Delay Mode

@@ -401,12 +482,14 @@ export function SmartPlugCard({ plug, onEdit }: SmartPlugCardProps) { /> )} - {/* Power On Confirmation */} + {/* Power On / Run Script Confirmation */} {showPowerOnConfirm && ( { controlMutation.mutate('on'); diff --git a/frontend/src/components/SwitchbarPopover.tsx b/frontend/src/components/SwitchbarPopover.tsx index 081387cbe..2a7f6d606 100644 --- a/frontend/src/components/SwitchbarPopover.tsx +++ b/frontend/src/components/SwitchbarPopover.tsx @@ -1,6 +1,6 @@ import { useState } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { Plug, Power, PowerOff, Loader2, Wifi, WifiOff, Zap } from 'lucide-react'; +import { Plug, Power, PowerOff, Loader2, Wifi, WifiOff, Zap, Play } from 'lucide-react'; import { api } from '../api/client'; import type { SmartPlug } from '../api/client'; import { ConfirmModal } from './ConfirmModal'; @@ -32,6 +32,9 @@ function SwitchItem({ plug }: { plug: SmartPlug }) { const isReachable = status?.reachable ?? false; const isPending = controlMutation.isPending; + // Check if this is a HA script entity + const isScript = plug.plug_type === 'homeassistant' && plug.ha_entity_id?.startsWith('script.'); + const handleConfirm = () => { if (confirmAction) { controlMutation.mutate(confirmAction); @@ -43,18 +46,33 @@ function SwitchItem({ plug }: { plug: SmartPlug }) { <>
-
- +
+ {isScript ? ( + + ) : ( + + )}
-

{plug.name}

+
+

{plug.name}

+ {isScript && ( + Script + )} +
{statusLoading ? ( + ) : isScript ? ( + + {isReachable ? 'Ready' : 'Offline'} + ) : isReachable ? ( <> - {status?.state || 'Unknown'} + + {status?.state || 'Unknown'} + {status?.energy?.power !== null && status?.energy?.power !== undefined && ( <> | @@ -74,38 +92,57 @@ function SwitchItem({ plug }: { plug: SmartPlug }) {
- - + {isScript ? ( + /* Script: single Run button */ + + ) : ( + /* Regular: On/Off buttons */ + <> + + + + )}
{confirmAction && ( setConfirmAction(null)} diff --git a/frontend/src/pages/FileManagerPage.tsx b/frontend/src/pages/FileManagerPage.tsx index 77e51ef9c..944e85cd5 100644 --- a/frontend/src/pages/FileManagerPage.tsx +++ b/frontend/src/pages/FileManagerPage.tsx @@ -50,6 +50,7 @@ import { Button } from '../components/Button'; import { ConfirmModal } from '../components/ConfirmModal'; import { PrintModal } from '../components/PrintModal'; import { useToast } from '../contexts/ToastContext'; +import { useIsMobile } from '../hooks/useIsMobile'; type SortField = 'name' | 'date' | 'size' | 'type' | 'prints'; type SortDirection = 'asc' | 'desc'; @@ -855,6 +856,7 @@ function isSlicedFilename(filename: string): boolean { interface FileCardProps { file: LibraryFileListItem; isSelected: boolean; + isMobile: boolean; onSelect: (id: number) => void; onDelete: (id: number) => void; onDownload: (id: number) => void; @@ -865,7 +867,7 @@ interface FileCardProps { thumbnailVersion?: number; } -function FileCard({ file, isSelected, onSelect, onDelete, onDownload, onAddToQueue, onPrint, onRename, onGenerateThumbnail, thumbnailVersion }: FileCardProps) { +function FileCard({ file, isSelected, isMobile, onSelect, onDelete, onDownload, onAddToQueue, onPrint, onRename, onGenerateThumbnail, thumbnailVersion }: FileCardProps) { const [showActions, setShowActions] = useState(false); return ( @@ -921,7 +923,7 @@ function FileCard({ file, isSelected, onSelect, onDelete, onDownload, onAddToQue
{/* Actions - always visible on mobile, hover on desktop */} -
e.stopPropagation()}> +
e.stopPropagation()}>
)} - {/* Selection toolbar */} + {/* Selection toolbar - sticky on mobile below search bar */} {filteredAndSortedFiles.length > 0 && ( -
+
{/* Select all / Deselect all */} {selectedFiles.length === filteredAndSortedFiles.length && selectedFiles.length > 0 ? (
) : viewMode === 'grid' ? ( -
+
{filteredAndSortedFiles.map((file) => ( setDeleteConfirm({ type: 'file', id })} onDownload={handleDownload} @@ -1845,10 +1865,10 @@ export function FileManagerPage() {
) : ( -
+
- {/* List header */} -
+ {/* List header - hidden on mobile, show simplified on small screens */} +
Name
Type
diff --git a/frontend/src/pages/PrintersPage.tsx b/frontend/src/pages/PrintersPage.tsx index 94cb0cd48..8b3b76ae6 100644 --- a/frontend/src/pages/PrintersPage.tsx +++ b/frontend/src/pages/PrintersPage.tsx @@ -1093,6 +1093,12 @@ function PrinterCard({ queryFn: () => api.getSmartPlugByPrinter(printer.id), }); + // Fetch script plugs for this printer (for multi-device control) + const { data: scriptPlugs } = useQuery({ + queryKey: ['scriptPlugsByPrinter', printer.id], + queryFn: () => api.getScriptPlugsByPrinter(printer.id), + }); + // Fetch smart plug status if plug exists (faster refresh for energy monitoring) const { data: plugStatus } = useQuery({ queryKey: ['smartPlugStatus', smartPlug?.id], @@ -1155,6 +1161,15 @@ function PrinterCard({ }, }); + // Run script mutation + const runScriptMutation = useMutation({ + mutationFn: (scriptId: number) => api.controlSmartPlug(scriptId, 'on'), + onSuccess: () => { + showToast('Script triggered'); + }, + onError: (error: Error) => showToast(error.message || 'Failed to run script', 'error'), + }); + // Print control mutations const stopPrintMutation = useMutation({ mutationFn: () => api.stopPrint(printer.id), @@ -2683,6 +2698,28 @@ function PrinterCard({
+ + {/* Script buttons row */} + {scriptPlugs && scriptPlugs.length > 0 && ( +
+ + Scripts: +
+ {scriptPlugs.map(script => ( + + ))} +
+
+ )}
)} diff --git a/frontend/src/pages/SettingsPage.tsx b/frontend/src/pages/SettingsPage.tsx index 944babd95..f68c462f1 100644 --- a/frontend/src/pages/SettingsPage.tsx +++ b/frontend/src/pages/SettingsPage.tsx @@ -1660,7 +1660,7 @@ export function SettingsPage() {

- Connect to Home Assistant to control smart plugs via HA's REST API. Supports switch, light, and input_boolean entities. + Connect to Home Assistant to control smart plugs via HA's REST API. Supports switch, light, input_boolean, and script entities.

diff --git a/install/README.md b/install/README.md new file mode 100644 index 000000000..658303ef2 --- /dev/null +++ b/install/README.md @@ -0,0 +1,234 @@ +# BamBuddy Installation Scripts + +Interactive installation scripts for BamBuddy with support for both native and Docker deployments. + +## Quick Start + +### Docker Installation (Recommended) + +**Linux/macOS:** +```bash +curl -fsSL https://raw.githubusercontent.com/maziggy/bambuddy/main/install/docker-install.sh | bash +``` + +### Native Installation + +**Linux/macOS:** +```bash +curl -fsSL https://raw.githubusercontent.com/maziggy/bambuddy/main/install/install.sh | bash +``` + +--- + +## Scripts Overview + +| Script | Platform | Method | +|--------|----------|--------| +| `install.sh` | Linux, macOS | Native (Python venv) | +| `docker-install.sh` | Linux, macOS | Docker | + +--- + +## Native Installation Scripts + +### `install.sh` (Linux/macOS) + +Installs BamBuddy with Python virtual environment and optional systemd/launchd service. + +**Supported Systems:** +- Debian/Ubuntu (apt) +- RHEL/Fedora/CentOS (dnf/yum) +- Arch Linux (pacman) +- openSUSE (zypper) +- macOS (Homebrew) + +**Options:** +``` +--path PATH Installation directory (default: /opt/bambuddy) +--port PORT Port to listen on (default: 8000) +--tz TIMEZONE Timezone (default: system timezone) +--data-dir PATH Data directory (default: INSTALL_PATH/data) +--log-dir PATH Log directory (default: INSTALL_PATH/logs) +--debug Enable debug mode +--log-level LEVEL Log level: DEBUG, INFO, WARNING, ERROR (default: INFO) +--no-service Skip systemd/launchd service setup +--yes, -y Non-interactive mode, accept defaults +``` + +**Examples:** +```bash +# Interactive installation +./install.sh + +# Unattended with custom settings +./install.sh --path /srv/bambuddy --port 3000 --tz America/New_York --yes + +# Minimal unattended +./install.sh -y + +# Skip service setup +./install.sh --no-service -y +``` + +--- + +## Docker Installation Scripts + +### `docker-install.sh` (Linux/macOS) + +Installs BamBuddy using Docker containers. + +**Options:** +``` +--path PATH Installation directory (default: ~/bambuddy) +--port PORT Port to expose (default: 8000) +--tz TIMEZONE Timezone (default: system timezone) +--build Build from source instead of using pre-built image +--yes, -y Non-interactive mode, accept defaults +``` + +**Examples:** +```bash +# Interactive installation +./docker-install.sh + +# Unattended with custom settings +./docker-install.sh --path /srv/bambuddy --port 3000 --tz Europe/Berlin --yes + +# Build from source +./docker-install.sh --build --yes +``` + +--- + +## Configuration Options + +All scripts support these configuration options: + +| Option | Description | Default | +|--------|-------------|---------| +| Install Path | Where BamBuddy is installed | `/opt/bambuddy` (Linux/Docker) | +| Port | HTTP port for web interface | `8000` | +| Timezone | Server timezone | System timezone or `UTC` | +| Data Directory | Database and archives | `INSTALL_PATH/data` | +| Log Directory | Application logs | `INSTALL_PATH/logs` | +| Debug Mode | Enable verbose logging | `false` | +| Log Level | INFO, WARNING, ERROR, DEBUG | `INFO` | + +--- + +## Post-Installation + +### Accessing BamBuddy + +After installation, open your browser to: +``` +http://localhost:8000 +``` + +Or use the port you specified during installation. + +### Service Management + +**Linux (systemd):** +```bash +sudo systemctl status bambuddy # Check status +sudo systemctl start bambuddy # Start +sudo systemctl stop bambuddy # Stop +sudo systemctl restart bambuddy # Restart +sudo journalctl -u bambuddy -f # View logs +``` + +**macOS (launchd):** +```bash +launchctl list | grep bambuddy # Check status +launchctl load ~/Library/LaunchAgents/com.bambuddy.app.plist # Start +launchctl unload ~/Library/LaunchAgents/com.bambuddy.app.plist # Stop +``` + +**Docker:** +```bash +docker compose ps # Check status +docker compose up -d # Start +docker compose down # Stop +docker compose restart # Restart +docker compose logs -f # View logs +``` + +### Updating + +**Native installation:** +```bash +cd /opt/bambuddy +git pull +source venv/bin/activate +pip install -r requirements.txt +cd frontend && npm ci && npm run build +sudo systemctl restart bambuddy # Linux +``` + +**Docker (pre-built image):** +```bash +cd ~/bambuddy +docker compose pull +docker compose up -d +``` + +**Docker (from source):** +```bash +cd ~/bambuddy +git pull +docker compose up -d --build +``` + +--- + +## Troubleshooting + +### Permission Denied (Linux) +Run with `sudo` or ensure your user has appropriate permissions: +```bash +sudo ./install.sh +``` + +### Docker: Printer Discovery Not Working +Docker Desktop for macOS doesn't support host networking. Add printers manually by IP address in the BamBuddy web interface. + +### Service Won't Start +Check logs for errors: +```bash +# Linux +sudo journalctl -u bambuddy -n 50 + +# Docker +docker compose logs bambuddy +``` + +### Port Already in Use +Choose a different port during installation or stop the conflicting service: +```bash +# Find what's using port 8000 +sudo lsof -i :8000 # Linux/macOS +``` + +--- + +## Requirements + +### Native Installation +- Python 3.10+ (automatically installed if missing) +- Node.js 18+ (automatically installed if missing) +- Git (automatically installed if missing) +- ~500MB disk space + +### Docker Installation +- Docker Engine 20+ or Docker Desktop +- ~1GB disk space (includes image) + +--- + +## Support + +- **Documentation:** https://wiki.bambuddy.cool +- **Discord:** https://discord.gg/aFS3ZfScHM +- **Issues:** https://github.com/maziggy/bambuddy/issues diff --git a/install/docker-install.sh b/install/docker-install.sh new file mode 100755 index 000000000..c8d48833a --- /dev/null +++ b/install/docker-install.sh @@ -0,0 +1,541 @@ +#!/usr/bin/env bash +# +# BamBuddy Docker Installation Script +# Supports: Linux (all distros), macOS +# +# Usage: +# Interactive: curl -fsSL https://raw.githubusercontent.com/maziggy/bambuddy/main/install/docker-install.sh | bash +# Unattended: ./docker-install.sh --path /opt/bambuddy --port 8000 --yes +# +# Options: +# --path PATH Installation directory (default: /opt/bambuddy) +# --port PORT Port to expose (default: 8000) +# --bind ADDRESS Bind address: 0.0.0.0 (network) or 127.0.0.1 (local only) +# --tz TIMEZONE Timezone (default: system timezone or UTC) +# --build Build from source instead of using pre-built image +# --yes, -y Non-interactive mode, accept defaults +# --help, -h Show this help message +# + +set -e + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +CYAN='\033[0;36m' +NC='\033[0m' # No Color +BOLD='\033[1m' + +# Default values +DEFAULT_INSTALL_PATH="/opt/bambuddy" +DEFAULT_PORT="8000" +DEFAULT_BIND_ADDRESS="0.0.0.0" + +# Script variables +INSTALL_PATH="" +PORT="" +BIND_ADDRESS="" +TIMEZONE="" +BUILD_FROM_SOURCE="false" +NON_INTERACTIVE="false" +OS_TYPE="" +DOCKER_CMD="" + +# ----------------------------------------------------------------------------- +# Helper Functions +# ----------------------------------------------------------------------------- + +print_banner() { + echo -e "${CYAN}" + echo "╔════════════════════════════════════════════════════════╗" + echo "║ ║" + echo "║ ____ _ _ _ ║" + echo "║ | __ ) __ _ _ __ ___ | |__ _ _ __| | __| |_ _ ║" + echo "║ | _ \\ / _\` | '_ \` _ \\| '_ \\| | | |/ _\` |/ _\` | | | | ║" + echo "║ | |_) | (_| | | | | | | |_) | |_| | (_| | (_| | |_| | ║" + echo "║ |____/ \\__,_|_| |_| |_|_.__/ \\__,_|\\__,_|\\__,_|\\__, | ║" + echo "║ |___/ ║" + echo "║ ║" + echo "║ Docker Installation Script ║" + echo "║ ║" + echo "╚════════════════════════════════════════════════════════╝" + echo -e "${NC}" +} + +log_info() { + echo -e "${BLUE}[INFO]${NC} $1" +} + +log_success() { + echo -e "${GREEN}[OK]${NC} $1" +} + +log_warn() { + echo -e "${YELLOW}[WARN]${NC} $1" +} + +log_error() { + echo -e "${RED}[ERROR]${NC} $1" +} + +prompt() { + local prompt_text="$1" + local default_value="$2" + local var_name="$3" + + if [[ "$NON_INTERACTIVE" == "true" ]]; then + eval "$var_name=\"$default_value\"" + return + fi + + if [[ -n "$default_value" ]]; then + echo -en "${BOLD}$prompt_text${NC} [${CYAN}$default_value${NC}]: " + else + echo -en "${BOLD}$prompt_text${NC}: " + fi + + read -r input + if [[ -z "$input" ]]; then + eval "$var_name=\"$default_value\"" + else + eval "$var_name=\"$input\"" + fi +} + +prompt_yes_no() { + local prompt_text="$1" + local default="$2" # y or n + + if [[ "$NON_INTERACTIVE" == "true" ]]; then + [[ "$default" == "y" ]] && return 0 || return 1 + fi + + local yn_hint="[y/n]" + [[ "$default" == "y" ]] && yn_hint="[Y/n]" + [[ "$default" == "n" ]] && yn_hint="[y/N]" + + while true; do + echo -en "${BOLD}$prompt_text${NC} $yn_hint: " + read -r yn + [[ -z "$yn" ]] && yn="$default" + case "$yn" in + [Yy]* ) return 0;; + [Nn]* ) return 1;; + * ) echo "Please answer yes or no.";; + esac + done +} + +show_help() { + echo "BamBuddy Docker Installation Script" + echo "" + echo "Usage: $0 [OPTIONS]" + echo "" + echo "Options:" + echo " --path PATH Installation directory (default: /opt/bambuddy)" + echo " --port PORT Port to expose (default: 8000)" + echo " --bind ADDRESS Bind address: 0.0.0.0 (network) or 127.0.0.1 (local only)" + echo " --tz TIMEZONE Timezone (default: system timezone or UTC)" + echo " --build Build from source instead of using pre-built image" + echo " --yes, -y Non-interactive mode, accept defaults" + echo " --help, -h Show this help message" + echo "" + echo "Examples:" + echo " Interactive installation:" + echo " ./docker-install.sh" + echo "" + echo " Unattended installation with custom settings:" + echo " ./docker-install.sh --path /srv/bambuddy --port 3000 --tz America/New_York --yes" + echo "" + echo " Build from source:" + echo " ./docker-install.sh --build --yes" + exit 0 +} + +# ----------------------------------------------------------------------------- +# System Detection +# ----------------------------------------------------------------------------- + +detect_os() { + if [[ "$OSTYPE" == "darwin"* ]]; then + OS_TYPE="macos" + return + fi + + if [[ -f /etc/os-release ]]; then + OS_TYPE="linux" + else + log_error "Cannot detect operating system" + exit 1 + fi +} + +detect_docker() { + # Check for docker compose (v2) or docker-compose (v1) + if docker compose version &>/dev/null 2>&1; then + DOCKER_CMD="docker compose" + log_success "Found Docker Compose v2" + return 0 + elif docker-compose --version &>/dev/null 2>&1; then + DOCKER_CMD="docker-compose" + log_success "Found Docker Compose v1" + return 0 + fi + return 1 +} + +detect_timezone() { + if [[ -n "$TIMEZONE" ]]; then + return 0 + fi + + # Try to get system timezone (with error handling for set -e) + TIMEZONE="" + if [[ -f /etc/timezone ]]; then + TIMEZONE=$(cat /etc/timezone 2>/dev/null) || true + fi + + if [[ -z "$TIMEZONE" ]] && [[ -L /etc/localtime ]]; then + TIMEZONE=$(readlink /etc/localtime 2>/dev/null | sed 's|.*/zoneinfo/||') || true + fi + + if [[ -z "$TIMEZONE" ]] && command -v timedatectl &>/dev/null; then + TIMEZONE=$(timedatectl show --property=Timezone --value 2>/dev/null) || true + fi + + # Default to UTC if not found (use if/then to avoid set -e issue with &&) + if [[ -z "$TIMEZONE" ]]; then + TIMEZONE="UTC" + fi + return 0 +} + +# ----------------------------------------------------------------------------- +# Installation Functions +# ----------------------------------------------------------------------------- + +install_docker() { + log_info "Docker not found, installing..." + + case "$OS_TYPE" in + linux) + # Use Docker's convenience script + curl -fsSL https://get.docker.com | sh + + # Add current user to docker group + if [[ -n "$SUDO_USER" ]]; then + sudo usermod -aG docker "$SUDO_USER" + log_warn "Added $SUDO_USER to docker group. You may need to log out and back in." + else + sudo usermod -aG docker "$USER" + log_warn "Added $USER to docker group. You may need to log out and back in." + fi + + # Start Docker service + sudo systemctl enable docker + sudo systemctl start docker + ;; + macos) + log_error "Docker Desktop not found." + log_error "Please install Docker Desktop for Mac from: https://www.docker.com/products/docker-desktop" + exit 1 + ;; + esac + + log_success "Docker installed" +} + +create_install_dir() { + log_info "Creating installation directory..." + + mkdir -p "$INSTALL_PATH" + cd "$INSTALL_PATH" + + log_success "Directory created: $INSTALL_PATH" +} + +download_compose_file() { + log_info "Downloading docker-compose.yml..." + + if [[ "$BUILD_FROM_SOURCE" == "true" ]]; then + # Clone the full repo for building + if [[ -d ".git" ]]; then + log_info "Existing repository found, updating..." + git fetch origin + git reset --hard origin/main + else + git clone https://github.com/maziggy/bambuddy.git . + fi + else + # Just download the compose file + curl -fsSL -o docker-compose.yml \ + https://raw.githubusercontent.com/maziggy/bambuddy/main/docker-compose.yml + fi + + log_success "docker-compose.yml ready" +} + +create_env_file() { + log_info "Creating environment configuration..." + + cat > .env << EOF +# BamBuddy Docker Configuration +# Generated by docker-install.sh on $(date) + +# Port BamBuddy runs on +PORT=$PORT + +# Timezone +TZ=$TIMEZONE +EOF + + log_success "Environment file created" +} + +customize_compose() { + # Detect if we need to disable host networking (macOS/Windows in Docker Desktop) + if [[ "$OS_TYPE" == "macos" ]]; then + log_warn "Docker Desktop detected. Host networking is not supported." + log_info "Modifying docker-compose.yml for port mapping..." + + # Create a modified compose file for macOS + if [[ -f docker-compose.yml ]]; then + # Comment out network_mode: host and uncomment ports section + sed -i.bak \ + -e 's/^[[:space:]]*network_mode: host/# network_mode: host/' \ + -e 's/^[[:space:]]*#ports:/ ports:/' \ + -e 's/^[[:space:]]*#[[:space:]]*- "\${PORT:-8000}:8000"/ - "\${PORT:-8000}:8000"/' \ + docker-compose.yml + + log_warn "Printer discovery may not work. Add printers manually by IP address." + fi + fi +} + +start_container() { + log_info "Starting BamBuddy..." + + if [[ "$BUILD_FROM_SOURCE" == "true" ]]; then + $DOCKER_CMD up -d --build + else + $DOCKER_CMD up -d + fi + + # Wait for container to start + log_info "Waiting for container to start..." + local max_attempts=15 + local attempt=0 + + while [[ $attempt -lt $max_attempts ]]; do + # Check if container is running (Up) + if $DOCKER_CMD ps | grep -q "Up"; then + log_success "BamBuddy container is running" + return 0 + fi + + # Check if container failed + if $DOCKER_CMD ps -a | grep -q "Exited"; then + log_error "Container failed to start" + log_info "Check logs with: $DOCKER_CMD logs bambuddy" + return 1 + fi + + sleep 2 + ((attempt++)) + done + + log_warn "Container may still be starting. Check with: $DOCKER_CMD ps" +} + +# ----------------------------------------------------------------------------- +# Main Installation Flow +# ----------------------------------------------------------------------------- + +parse_args() { + while [[ $# -gt 0 ]]; do + case "$1" in + --path) + INSTALL_PATH="$2" + shift 2 + ;; + --port) + PORT="$2" + shift 2 + ;; + --bind) + BIND_ADDRESS="$2" + shift 2 + ;; + --tz) + TIMEZONE="$2" + shift 2 + ;; + --build) + BUILD_FROM_SOURCE="true" + shift + ;; + --yes|-y) + NON_INTERACTIVE="true" + shift + ;; + --help|-h) + show_help + ;; + *) + log_error "Unknown option: $1" + show_help + ;; + esac + done +} + +gather_config() { + echo "" + echo -e "${BOLD}Installation Configuration${NC}" + echo -e "${CYAN}─────────────────────────────────────────${NC}" + echo "" + + # Installation path + [[ -z "$INSTALL_PATH" ]] && prompt "Installation directory" "$DEFAULT_INSTALL_PATH" INSTALL_PATH + + # Port + [[ -z "$PORT" ]] && prompt "Port to expose" "$DEFAULT_PORT" PORT + + # Bind address + if [[ -z "$BIND_ADDRESS" ]]; then + echo "" + echo "Network access:" + echo " 0.0.0.0 - Accessible from other devices on your network (recommended)" + echo " 127.0.0.1 - Only accessible from this machine" + prompt "Bind address" "$DEFAULT_BIND_ADDRESS" BIND_ADDRESS + fi + + # Timezone + detect_timezone + prompt "Timezone" "$TIMEZONE" TIMEZONE + + # Build from source? + if [[ "$BUILD_FROM_SOURCE" != "true" ]] && [[ "$NON_INTERACTIVE" != "true" ]]; then + if prompt_yes_no "Build from source? (No = use pre-built image)" "n"; then + BUILD_FROM_SOURCE="true" + fi + fi + + # Confirm + echo "" + echo -e "${BOLD}Installation Summary${NC}" + echo -e "${CYAN}─────────────────────────────────────────${NC}" + echo -e " Install path: ${GREEN}$INSTALL_PATH${NC}" + echo -e " Port: ${GREEN}$PORT${NC}" + echo -e " Bind address: ${GREEN}$BIND_ADDRESS${NC}" + echo -e " Timezone: ${GREEN}$TIMEZONE${NC}" + echo -e " Build source: ${GREEN}$BUILD_FROM_SOURCE${NC}" + echo "" + + if ! prompt_yes_no "Proceed with installation?" "y"; then + echo "Installation cancelled." + exit 0 + fi +} + +main() { + parse_args "$@" + print_banner + + # Check if running via pipe (curl | bash) - interactive mode won't work + if [[ ! -t 0 ]] && [[ "$NON_INTERACTIVE" != "true" ]]; then + log_error "Interactive mode requires a terminal." + log_info "When using 'curl | bash', you must use non-interactive mode:" + echo "" + echo " curl -fsSL URL | bash -s -- --yes" + echo "" + log_info "Or download and run directly:" + echo "" + echo " curl -fsSL URL -o docker-install.sh && chmod +x docker-install.sh && ./docker-install.sh" + echo "" + exit 1 + fi + + # Detect system + log_info "Detecting system..." + detect_os + log_success "Detected: $OS_TYPE" + + # Check for Docker + if ! command -v docker &>/dev/null; then + install_docker + fi + + if ! detect_docker; then + log_error "Docker Compose not found. Please install Docker Compose." + exit 1 + fi + + # Check if Docker daemon is running + if ! docker info &>/dev/null; then + log_error "Docker daemon is not running. Please start Docker and try again." + exit 1 + fi + + # Gather configuration + gather_config + + # Install steps + echo "" + echo -e "${BOLD}Starting Installation${NC}" + echo -e "${CYAN}─────────────────────────────────────────${NC}" + echo "" + + create_install_dir + download_compose_file + create_env_file + customize_compose + start_container + + # Done! + echo "" + echo -e "${GREEN}╔══════════════════════════════════════════════════════════════╗${NC}" + echo -e "${GREEN}║ ║${NC}" + echo -e "${GREEN}║ Installation Complete! ║${NC}" + echo -e "${GREEN}║ ║${NC}" + echo -e "${GREEN}╚══════════════════════════════════════════════════════════════╝${NC}" + echo "" + # Show appropriate URL based on bind address + if [[ "$BIND_ADDRESS" == "0.0.0.0" ]]; then + local ip_addr + ip_addr=$(hostname -I 2>/dev/null | awk '{print $1}') || ip_addr="" + echo -e " ${BOLD}Access BamBuddy:${NC} ${CYAN}http://localhost:$PORT${NC}" + echo -e " ${CYAN}http://$ip_addr:$PORT${NC} (from other devices)" + else + echo -e " ${BOLD}Access BamBuddy:${NC} ${CYAN}http://localhost:$PORT${NC}" + fi + echo "" + echo -e " ${BOLD}Manage container:${NC}" + echo -e " Status: cd $INSTALL_PATH && $DOCKER_CMD ps" + echo -e " Logs: cd $INSTALL_PATH && $DOCKER_CMD logs -f bambuddy" + echo -e " Stop: cd $INSTALL_PATH && $DOCKER_CMD down" + echo -e " Start: cd $INSTALL_PATH && $DOCKER_CMD up -d" + echo -e " Restart: cd $INSTALL_PATH && $DOCKER_CMD restart" + echo "" + echo -e " ${BOLD}Update BamBuddy:${NC}" + if [[ "$BUILD_FROM_SOURCE" == "true" ]]; then + echo -e " cd $INSTALL_PATH && git pull && $DOCKER_CMD up -d --build" + else + echo -e " cd $INSTALL_PATH && $DOCKER_CMD pull && $DOCKER_CMD up -d" + fi + echo "" + echo -e " ${BOLD}Data location:${NC} Docker volumes (bambuddy_data, bambuddy_logs)" + echo "" + echo -e " ${BOLD}Documentation:${NC} ${CYAN}https://wiki.bambuddy.cool${NC}" + echo "" + + if [[ "$OS_TYPE" == "macos" ]]; then + echo -e " ${YELLOW}Note:${NC} Printer discovery may not work with Docker Desktop." + echo -e " Add printers manually using their IP address." + echo "" + fi +} + +main "$@" diff --git a/install/install.sh b/install/install.sh new file mode 100755 index 000000000..a6b15709d --- /dev/null +++ b/install/install.sh @@ -0,0 +1,883 @@ +#!/usr/bin/env bash +# +# BamBuddy Native Installation Script +# Supports: Debian/Ubuntu, RHEL/Fedora/CentOS, Arch Linux, macOS +# +# Usage: +# Interactive: curl -fsSL https://raw.githubusercontent.com/maziggy/bambuddy/main/install/install.sh | bash +# Unattended: ./install.sh --path /opt/bambuddy --port 8000 --yes +# +# Options: +# --path PATH Installation directory (default: /opt/bambuddy) +# --port PORT Port to listen on (default: 8000) +# --bind ADDRESS Bind address: 0.0.0.0 (network) or 127.0.0.1 (local only) +# --tz TIMEZONE Timezone (default: system timezone or UTC) +# --data-dir PATH Data directory (default: INSTALL_PATH/data) +# --log-dir PATH Log directory (default: INSTALL_PATH/logs) +# --debug Enable debug mode +# --log-level LEVEL Log level: DEBUG, INFO, WARNING, ERROR (default: INFO) +# --no-service Skip systemd service setup (Linux only) +# --set-system-tz Set system timezone to match (for unattended installs) +# --yes, -y Non-interactive mode, accept defaults +# --help, -h Show this help message +# + +set -e + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +CYAN='\033[0;36m' +NC='\033[0m' # No Color +BOLD='\033[1m' + +# Default values +DEFAULT_INSTALL_PATH="/opt/bambuddy" +DEFAULT_PORT="8000" +DEFAULT_BIND_ADDRESS="0.0.0.0" +DEFAULT_LOG_LEVEL="INFO" +DEFAULT_DEBUG="false" + +# Script variables +INSTALL_PATH="" +PORT="" +BIND_ADDRESS="" +TIMEZONE="" +DATA_DIR="" +LOG_DIR="" +DEBUG_MODE="" +LOG_LEVEL="" +SKIP_SERVICE="false" +SET_SYSTEM_TZ="" +NON_INTERACTIVE="false" +OS_TYPE="" +PKG_MANAGER="" +PYTHON_CMD="" +SERVICE_USER="bambuddy" + +# ----------------------------------------------------------------------------- +# Helper Functions +# ----------------------------------------------------------------------------- + +print_banner() { + echo -e "${CYAN}" + echo "╔════════════════════════════════════════════════════════╗" + echo "║ ║" + echo "║ ____ _ _ _ ║" + echo "║ | __ ) __ _ _ __ ___ | |__ _ _ __| | __| |_ _ ║" + echo "║ | _ \\ / _\` | '_ \` _ \\| '_ \\| | | |/ _\` |/ _\` | | | | ║" + echo "║ | |_) | (_| | | | | | | |_) | |_| | (_| | (_| | |_| | ║" + echo "║ |____/ \\__,_|_| |_| |_|_.__/ \\__,_|\\__,_|\\__,_|\\__, | ║" + echo "║ |___/ ║" + echo "║ ║" + echo "║ Native Installation Script ║" + echo "║ ║" + echo "╚════════════════════════════════════════════════════════╝" + echo -e "${NC}" +} + +log_info() { + echo -e "${BLUE}[INFO]${NC} $1" +} + +log_success() { + echo -e "${GREEN}[OK]${NC} $1" +} + +log_warn() { + echo -e "${YELLOW}[WARN]${NC} $1" +} + +log_error() { + echo -e "${RED}[ERROR]${NC} $1" +} + +prompt() { + local prompt_text="$1" + local default_value="$2" + local var_name="$3" + + if [[ "$NON_INTERACTIVE" == "true" ]]; then + eval "$var_name=\"$default_value\"" + return + fi + + if [[ -n "$default_value" ]]; then + echo -en "${BOLD}$prompt_text${NC} [${CYAN}$default_value${NC}]: " + else + echo -en "${BOLD}$prompt_text${NC}: " + fi + + read -r input + if [[ -z "$input" ]]; then + eval "$var_name=\"$default_value\"" + else + eval "$var_name=\"$input\"" + fi +} + +prompt_yes_no() { + local prompt_text="$1" + local default="$2" # y or n + + if [[ "$NON_INTERACTIVE" == "true" ]]; then + [[ "$default" == "y" ]] && return 0 || return 1 + fi + + local yn_hint="[y/n]" + [[ "$default" == "y" ]] && yn_hint="[Y/n]" + [[ "$default" == "n" ]] && yn_hint="[y/N]" + + while true; do + echo -en "${BOLD}$prompt_text${NC} $yn_hint: " + read -r yn + [[ -z "$yn" ]] && yn="$default" + case "$yn" in + [Yy]* ) return 0;; + [Nn]* ) return 1;; + * ) echo "Please answer yes or no.";; + esac + done +} + +show_help() { + echo "BamBuddy Native Installation Script" + echo "" + echo "Usage: $0 [OPTIONS]" + echo "" + echo "Options:" + echo " --path PATH Installation directory (default: /opt/bambuddy)" + echo " --port PORT Port to listen on (default: 8000)" + echo " --bind ADDRESS Bind address: 0.0.0.0 (network) or 127.0.0.1 (local only)" + echo " --tz TIMEZONE Timezone (default: system timezone or UTC)" + echo " --data-dir PATH Data directory (default: INSTALL_PATH/data)" + echo " --log-dir PATH Log directory (default: INSTALL_PATH/logs)" + echo " --debug Enable debug mode" + echo " --log-level LEVEL Log level: DEBUG, INFO, WARNING, ERROR (default: INFO)" + echo " --no-service Skip systemd service setup (Linux only)" + echo " --set-system-tz Set system timezone to match (for unattended installs)" + echo " --yes, -y Non-interactive mode, accept defaults" + echo " --help, -h Show this help message" + echo "" + echo "Examples:" + echo " Interactive installation:" + echo " ./install.sh" + echo "" + echo " Unattended installation with custom settings:" + echo " ./install.sh --path /srv/bambuddy --port 3000 --tz America/New_York --yes" + echo "" + echo " Minimal unattended installation:" + echo " ./install.sh -y" + exit 0 +} + +# ----------------------------------------------------------------------------- +# System Detection +# ----------------------------------------------------------------------------- + +detect_os() { + if [[ "$OSTYPE" == "darwin"* ]]; then + OS_TYPE="macos" + PKG_MANAGER="brew" + return + fi + + if [[ -f /etc/os-release ]]; then + . /etc/os-release + case "$ID" in + ubuntu|debian|raspbian|linuxmint|pop) + OS_TYPE="debian" + PKG_MANAGER="apt" + ;; + fedora|rhel|centos|rocky|almalinux|ol) + OS_TYPE="rhel" + if command -v dnf &>/dev/null; then + PKG_MANAGER="dnf" + else + PKG_MANAGER="yum" + fi + ;; + arch|manjaro|endeavouros) + OS_TYPE="arch" + PKG_MANAGER="pacman" + ;; + opensuse*|sles) + OS_TYPE="suse" + PKG_MANAGER="zypper" + ;; + *) + log_error "Unsupported Linux distribution: $ID" + exit 1 + ;; + esac + else + log_error "Cannot detect operating system" + exit 1 + fi +} + +detect_python() { + # Try python3 first, then python + if command -v python3 &>/dev/null; then + PYTHON_CMD="python3" + elif command -v python &>/dev/null; then + local version + version=$(python --version 2>&1 | cut -d' ' -f2 | cut -d'.' -f1) + if [[ "$version" -ge 3 ]]; then + PYTHON_CMD="python" + fi + fi + + if [[ -z "$PYTHON_CMD" ]]; then + return 1 + fi + + # Check version >= 3.10 + local version + version=$($PYTHON_CMD -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")') + local major minor + major=$(echo "$version" | cut -d'.' -f1) + minor=$(echo "$version" | cut -d'.' -f2) + + if [[ "$major" -lt 3 ]] || { [[ "$major" -eq 3 ]] && [[ "$minor" -lt 10 ]]; }; then + log_warn "Python $version found, but 3.10+ is required" + return 1 + fi + + log_success "Found Python $version" + return 0 +} + +detect_timezone() { + if [[ -n "$TIMEZONE" ]]; then + return 0 + fi + + # Try to get system timezone (with error handling for set -e) + TIMEZONE="" + if [[ -f /etc/timezone ]]; then + TIMEZONE=$(cat /etc/timezone 2>/dev/null) || true + fi + + if [[ -z "$TIMEZONE" ]] && [[ -L /etc/localtime ]]; then + TIMEZONE=$(readlink /etc/localtime 2>/dev/null | sed 's|.*/zoneinfo/||') || true + fi + + if [[ -z "$TIMEZONE" ]] && command -v timedatectl &>/dev/null; then + TIMEZONE=$(timedatectl show --property=Timezone --value 2>/dev/null) || true + fi + + # Default to UTC if not found (use if/then to avoid set -e issue with &&) + if [[ -z "$TIMEZONE" ]]; then + TIMEZONE="UTC" + fi + return 0 +} + +# ----------------------------------------------------------------------------- +# Package Installation +# ----------------------------------------------------------------------------- + +install_dependencies() { + log_info "Installing system dependencies..." + + case "$PKG_MANAGER" in + apt) + sudo apt-get update + sudo apt-get install -y python3 python3-pip python3-venv git curl ffmpeg + ;; + dnf|yum) + sudo $PKG_MANAGER install -y python3 python3-pip git curl ffmpeg + ;; + pacman) + sudo pacman -Sy --noconfirm python python-pip git curl ffmpeg + ;; + zypper) + sudo zypper install -y python3 python3-pip git curl ffmpeg + ;; + brew) + # Check if Homebrew is installed + if ! command -v brew &>/dev/null; then + log_error "Homebrew not found. Please install it first: https://brew.sh" + exit 1 + fi + brew install python git curl ffmpeg + ;; + esac + + log_success "System dependencies installed" +} + +# ----------------------------------------------------------------------------- +# Installation Steps +# ----------------------------------------------------------------------------- + +create_user() { + if [[ "$OS_TYPE" == "macos" ]]; then + return # Skip user creation on macOS + fi + + if id "$SERVICE_USER" &>/dev/null; then + log_info "User '$SERVICE_USER' already exists" + return + fi + + log_info "Creating service user '$SERVICE_USER'..." + sudo useradd --system --shell /usr/sbin/nologin --home-dir "$INSTALL_PATH" "$SERVICE_USER" + log_success "Service user created" +} + +download_bambuddy() { + log_info "Downloading BamBuddy..." + + if [[ -d "$INSTALL_PATH/.git" ]]; then + log_info "Existing installation found, updating..." + # Add safe.directory to avoid "dubious ownership" error when running as root + git config --global --add safe.directory "$INSTALL_PATH" 2>/dev/null || true + cd "$INSTALL_PATH" + git fetch origin + git reset --hard origin/main + # Ensure correct ownership after update + sudo chown -R "$SERVICE_USER:$SERVICE_USER" "$INSTALL_PATH" 2>/dev/null || true + else + sudo mkdir -p "$INSTALL_PATH" + sudo chown "$SERVICE_USER:$SERVICE_USER" "$INSTALL_PATH" 2>/dev/null || true + git clone https://github.com/maziggy/bambuddy.git "$INSTALL_PATH" + sudo chown -R "$SERVICE_USER:$SERVICE_USER" "$INSTALL_PATH" 2>/dev/null || true + fi + + log_success "BamBuddy downloaded to $INSTALL_PATH" +} + +setup_virtualenv() { + log_info "Setting up Python virtual environment..." + + cd "$INSTALL_PATH" + + if [[ "$OS_TYPE" == "macos" ]]; then + $PYTHON_CMD -m venv venv + source venv/bin/activate + else + sudo -u "$SERVICE_USER" $PYTHON_CMD -m venv venv 2>/dev/null || $PYTHON_CMD -m venv venv + source venv/bin/activate + fi + + pip install --upgrade pip + pip install -r requirements.txt + + log_success "Virtual environment configured" +} + +check_node_version() { + # Returns 0 if Node.js 20+ is available, 1 otherwise + if ! command -v node &>/dev/null; then + return 1 + fi + + local version + version=$(node --version 2>/dev/null | sed 's/^v//') + local major + major=$(echo "$version" | cut -d'.' -f1) + + if [[ "$major" -ge 20 ]]; then + log_success "Found Node.js v$version" + return 0 + else + log_warn "Found Node.js v$version (need 20+)" + return 1 + fi +} + +install_nodejs() { + log_info "Installing Node.js 22..." + case "$PKG_MANAGER" in + apt) + # Remove old nodejs if present + sudo apt-get remove -y nodejs npm 2>/dev/null || true + curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash - + sudo apt-get install -y nodejs + ;; + dnf|yum) + sudo $PKG_MANAGER remove -y nodejs npm 2>/dev/null || true + curl -fsSL https://rpm.nodesource.com/setup_22.x | sudo bash - + sudo $PKG_MANAGER install -y nodejs + ;; + pacman) + sudo pacman -S --noconfirm nodejs npm + ;; + zypper) + sudo zypper install -y nodejs22 + ;; + brew) + brew install node@22 + brew link --overwrite node@22 + ;; + *) + log_error "Please install Node.js 20+ manually: https://nodejs.org/" + exit 1 + ;; + esac + # Refresh PATH + hash -r 2>/dev/null || true +} + +build_frontend() { + log_info "Building frontend..." + + cd "$INSTALL_PATH/frontend" + + # Check for Node.js 20+ + if ! check_node_version; then + install_nodejs + # Verify installation + if ! check_node_version; then + log_error "Failed to install Node.js 20+. Please install manually." + exit 1 + fi + fi + + npm ci + npm run build + + log_success "Frontend built" +} + +create_directories() { + log_info "Creating data directories..." + + sudo mkdir -p "$DATA_DIR" "$LOG_DIR" + + if [[ "$OS_TYPE" != "macos" ]]; then + sudo chown -R "$SERVICE_USER:$SERVICE_USER" "$DATA_DIR" "$LOG_DIR" + fi + + log_success "Directories created" +} + +create_env_file() { + log_info "Creating environment configuration..." + + local env_file="$INSTALL_PATH/.env" + + # Note: Only include settings recognized by the app's pydantic Settings class + # Other settings (PORT, BIND_ADDRESS, DATA_DIR, LOG_DIR, TZ) are set in systemd service + cat > /tmp/bambuddy.env << EOF +# BamBuddy Configuration +# Generated by install.sh on $(date) + +# Debug mode (true = verbose logging) +DEBUG=$DEBUG_MODE + +# Log level (only used when DEBUG=false) +# Options: DEBUG, INFO, WARNING, ERROR +LOG_LEVEL=$LOG_LEVEL + +# Enable file logging +LOG_TO_FILE=true +EOF + + sudo mv /tmp/bambuddy.env "$env_file" + if [[ "$OS_TYPE" != "macos" ]]; then + sudo chown "$SERVICE_USER:$SERVICE_USER" "$env_file" + fi + sudo chmod 600 "$env_file" + + log_success "Environment file created at $env_file" +} + +create_systemd_service() { + if [[ "$OS_TYPE" == "macos" ]] || [[ "$SKIP_SERVICE" == "true" ]]; then + return + fi + + log_info "Creating systemd service..." + + cat > /tmp/bambuddy.service << EOF +[Unit] +Description=BamBuddy - Bambu Lab Print Management +Documentation=https://github.com/maziggy/bambuddy +After=network.target + +[Service] +Type=simple +User=$SERVICE_USER +Group=$SERVICE_USER +WorkingDirectory=$INSTALL_PATH + +# App settings from .env file +EnvironmentFile=$INSTALL_PATH/.env + +# Service settings (not in .env to avoid pydantic validation errors) +Environment="DATA_DIR=$DATA_DIR" +Environment="LOG_DIR=$LOG_DIR" +Environment="TZ=$TIMEZONE" + +ExecStart=$INSTALL_PATH/venv/bin/uvicorn backend.app.main:app --host $BIND_ADDRESS --port $PORT +Restart=on-failure +RestartSec=5 +StandardOutput=journal +StandardError=journal + +# Security hardening +NoNewPrivileges=true +PrivateTmp=true +ProtectSystem=strict +ProtectHome=true +ReadWritePaths=$DATA_DIR $LOG_DIR $INSTALL_PATH + +[Install] +WantedBy=multi-user.target +EOF + + sudo mv /tmp/bambuddy.service /etc/systemd/system/bambuddy.service + sudo systemctl daemon-reload + + log_success "Systemd service created" + + if prompt_yes_no "Enable BamBuddy to start on boot?" "y"; then + sudo systemctl enable bambuddy + log_success "Service enabled" + fi + + if prompt_yes_no "Start BamBuddy now?" "y"; then + sudo systemctl start bambuddy + sleep 2 + if sudo systemctl is-active --quiet bambuddy; then + log_success "BamBuddy is running" + else + log_warn "Service may have failed to start. Check: sudo journalctl -u bambuddy -f" + fi + fi +} + +create_launchd_service() { + if [[ "$OS_TYPE" != "macos" ]] || [[ "$SKIP_SERVICE" == "true" ]]; then + return + fi + + log_info "Creating launchd service..." + + local plist_path="$HOME/Library/LaunchAgents/com.bambuddy.app.plist" + + cat > "$plist_path" << EOF + + + + + Label + com.bambuddy.app + ProgramArguments + + $INSTALL_PATH/venv/bin/uvicorn + backend.app.main:app + --host + $BIND_ADDRESS + --port + $PORT + + WorkingDirectory + $INSTALL_PATH + EnvironmentVariables + + DEBUG + $DEBUG_MODE + LOG_LEVEL + $LOG_LEVEL + DATA_DIR + $DATA_DIR + LOG_DIR + $LOG_DIR + TZ + $TIMEZONE + + RunAtLoad + + KeepAlive + + StandardOutPath + $LOG_DIR/bambuddy.log + StandardErrorPath + $LOG_DIR/bambuddy.error.log + + +EOF + + log_success "Launchd plist created at $plist_path" + + if prompt_yes_no "Load BamBuddy service now?" "y"; then + launchctl load "$plist_path" + sleep 2 + if launchctl list | grep -q "com.bambuddy.app"; then + log_success "BamBuddy is running" + else + log_warn "Service may have failed to start. Check: cat $LOG_DIR/bambuddy.error.log" + fi + fi +} + +# ----------------------------------------------------------------------------- +# Main Installation Flow +# ----------------------------------------------------------------------------- + +parse_args() { + while [[ $# -gt 0 ]]; do + case "$1" in + --path) + INSTALL_PATH="$2" + shift 2 + ;; + --port) + PORT="$2" + shift 2 + ;; + --bind) + BIND_ADDRESS="$2" + shift 2 + ;; + --tz) + TIMEZONE="$2" + shift 2 + ;; + --data-dir) + DATA_DIR="$2" + shift 2 + ;; + --log-dir) + LOG_DIR="$2" + shift 2 + ;; + --debug) + DEBUG_MODE="true" + shift + ;; + --log-level) + LOG_LEVEL="$2" + shift 2 + ;; + --no-service) + SKIP_SERVICE="true" + shift + ;; + --set-system-tz) + SET_SYSTEM_TZ="true" + shift + ;; + --yes|-y) + NON_INTERACTIVE="true" + shift + ;; + --help|-h) + show_help + ;; + *) + log_error "Unknown option: $1" + show_help + ;; + esac + done +} + +gather_config() { + echo "" + echo -e "${BOLD}Installation Configuration${NC}" + echo -e "${CYAN}─────────────────────────────────────────${NC}" + echo "" + + # Installation path + [[ -z "$INSTALL_PATH" ]] && prompt "Installation directory" "$DEFAULT_INSTALL_PATH" INSTALL_PATH + + # Port + [[ -z "$PORT" ]] && prompt "Port to listen on" "$DEFAULT_PORT" PORT + + # Bind address + if [[ -z "$BIND_ADDRESS" ]]; then + echo "" + echo "Network access:" + echo " 0.0.0.0 - Accessible from other devices on your network (recommended)" + echo " 127.0.0.1 - Only accessible from this machine" + prompt "Bind address" "$DEFAULT_BIND_ADDRESS" BIND_ADDRESS + fi + + # Timezone + detect_timezone + prompt "Timezone" "$TIMEZONE" TIMEZONE + + # Offer to set system timezone if different from current (skip if already set via --set-system-tz) + if [[ -z "$SET_SYSTEM_TZ" ]]; then + local current_tz + current_tz=$(timedatectl show --property=Timezone --value 2>/dev/null) || true + if [[ -n "$TIMEZONE" ]] && [[ "$TIMEZONE" != "$current_tz" ]]; then + # Default to "n" so unattended installs don't change system TZ unless --set-system-tz is used + if prompt_yes_no "Set system timezone to $TIMEZONE?" "n"; then + SET_SYSTEM_TZ="true" + else + SET_SYSTEM_TZ="false" + fi + else + SET_SYSTEM_TZ="false" + fi + fi + + # Data directory + [[ -z "$DATA_DIR" ]] && DATA_DIR="$INSTALL_PATH/data" + prompt "Data directory" "$DATA_DIR" DATA_DIR + + # Log directory + [[ -z "$LOG_DIR" ]] && LOG_DIR="$INSTALL_PATH/logs" + prompt "Log directory" "$LOG_DIR" LOG_DIR + + # Debug mode + if [[ -z "$DEBUG_MODE" ]]; then + if prompt_yes_no "Enable debug mode?" "n"; then + DEBUG_MODE="true" + else + DEBUG_MODE="false" + fi + fi + + # Log level + if [[ -z "$LOG_LEVEL" ]]; then + echo "" + echo "Log levels: DEBUG, INFO, WARNING, ERROR" + prompt "Log level" "$DEFAULT_LOG_LEVEL" LOG_LEVEL + fi + + # Confirm + echo "" + echo -e "${BOLD}Installation Summary${NC}" + echo -e "${CYAN}─────────────────────────────────────────${NC}" + echo -e " Install path: ${GREEN}$INSTALL_PATH${NC}" + echo -e " Port: ${GREEN}$PORT${NC}" + echo -e " Bind address: ${GREEN}$BIND_ADDRESS${NC}" + echo -e " Timezone: ${GREEN}$TIMEZONE${NC}" + echo -e " Data dir: ${GREEN}$DATA_DIR${NC}" + echo -e " Log dir: ${GREEN}$LOG_DIR${NC}" + echo -e " Debug mode: ${GREEN}$DEBUG_MODE${NC}" + echo -e " Log level: ${GREEN}$LOG_LEVEL${NC}" + echo "" + + if ! prompt_yes_no "Proceed with installation?" "y"; then + echo "Installation cancelled." + exit 0 + fi +} + +main() { + parse_args "$@" + print_banner + + # Check if running via pipe (curl | bash) - interactive mode won't work + if [[ ! -t 0 ]] && [[ "$NON_INTERACTIVE" != "true" ]]; then + log_error "Interactive mode requires a terminal." + log_info "When using 'curl | bash', you must use non-interactive mode:" + echo "" + echo " curl -fsSL URL | bash -s -- --yes" + echo "" + log_info "Or download and run directly:" + echo "" + echo " curl -fsSL URL -o install.sh && chmod +x install.sh && ./install.sh" + echo "" + exit 1 + fi + + # Check for root (we need sudo for some operations) + if [[ "$EUID" -eq 0 ]] && [[ "$OS_TYPE" != "macos" ]]; then + log_warn "Running as root. Consider using a regular user with sudo privileges." + fi + + # Detect system + log_info "Detecting system..." + detect_os + log_success "Detected: $OS_TYPE (package manager: $PKG_MANAGER)" + + # Check/install Python + if ! detect_python; then + log_info "Python 3.10+ not found, will install..." + fi + + # Gather configuration + gather_config + + # Install steps + echo "" + echo -e "${BOLD}Starting Installation${NC}" + echo -e "${CYAN}─────────────────────────────────────────${NC}" + echo "" + + install_dependencies + detect_python || { log_error "Failed to install Python"; exit 1; } + + # Set system timezone if requested + if [[ "$SET_SYSTEM_TZ" == "true" ]]; then + log_info "Setting system timezone to $TIMEZONE..." + if [[ "$OS_TYPE" == "macos" ]]; then + sudo systemsetup -settimezone "$TIMEZONE" 2>/dev/null || true + else + sudo timedatectl set-timezone "$TIMEZONE" 2>/dev/null || true + fi + log_success "System timezone set to $TIMEZONE" + fi + + if [[ "$OS_TYPE" != "macos" ]]; then + create_user + else + SERVICE_USER="$USER" + fi + + download_bambuddy + setup_virtualenv + build_frontend + create_directories + create_env_file + + if [[ "$OS_TYPE" == "macos" ]]; then + create_launchd_service + else + create_systemd_service + fi + + # Done! + echo "" + echo -e "${GREEN}╔══════════════════════════════════════════════════════════════╗${NC}" + echo -e "${GREEN}║ ║${NC}" + echo -e "${GREEN}║ Installation Complete! ║${NC}" + echo -e "${GREEN}║ ║${NC}" + echo -e "${GREEN}╚══════════════════════════════════════════════════════════════╝${NC}" + echo "" + # Show appropriate URL based on bind address + if [[ "$BIND_ADDRESS" == "0.0.0.0" ]]; then + local ip_addr + ip_addr=$(hostname -I 2>/dev/null | awk '{print $1}') || ip_addr="" + echo -e " ${BOLD}Access BamBuddy:${NC} ${CYAN}http://localhost:$PORT${NC}" + echo -e " ${CYAN}http://$ip_addr:$PORT${NC} (from other devices)" + else + echo -e " ${BOLD}Access BamBuddy:${NC} ${CYAN}http://localhost:$PORT${NC}" + fi + echo "" + if [[ "$OS_TYPE" == "macos" ]]; then + echo -e " ${BOLD}Manage service:${NC}" + echo -e " Start: launchctl load ~/Library/LaunchAgents/com.bambuddy.app.plist" + echo -e " Stop: launchctl unload ~/Library/LaunchAgents/com.bambuddy.app.plist" + echo -e " Logs: tail -f $LOG_DIR/bambuddy.log" + else + echo -e " ${BOLD}Manage service:${NC}" + echo -e " Status: sudo systemctl status bambuddy" + echo -e " Start: sudo systemctl start bambuddy" + echo -e " Stop: sudo systemctl stop bambuddy" + echo -e " Logs: sudo journalctl -u bambuddy -f" + fi + echo "" + echo -e " ${BOLD}Update BamBuddy:${NC}" + echo -e " cd $INSTALL_PATH && git pull && source venv/bin/activate" + echo -e " pip install -r requirements.txt && cd frontend && npm ci && npm run build" + if [[ "$OS_TYPE" != "macos" ]]; then + echo -e " sudo systemctl restart bambuddy" + fi + echo "" + echo -e " ${BOLD}Documentation:${NC} ${CYAN}https://wiki.bambuddy.cool${NC}" + echo "" +} + +main "$@" diff --git a/static/assets/index-BDqcLQLO.js b/static/assets/index-BDqcLQLO.js new file mode 100644 index 000000000..4fe2d6093 --- /dev/null +++ b/static/assets/index-BDqcLQLO.js @@ -0,0 +1,8665 @@ +function jae(t,e){for(var n=0;nr[i]})}}}return Object.freeze(Object.defineProperty(t,Symbol.toStringTag,{value:"Module"}))}(function(){const e=document.createElement("link").relList;if(e&&e.supports&&e.supports("modulepreload"))return;for(const i of document.querySelectorAll('link[rel="modulepreload"]'))r(i);new MutationObserver(i=>{for(const a of i)if(a.type==="childList")for(const o of a.addedNodes)o.tagName==="LINK"&&o.rel==="modulepreload"&&r(o)}).observe(document,{childList:!0,subtree:!0});function n(i){const a={};return i.integrity&&(a.integrity=i.integrity),i.referrerPolicy&&(a.referrerPolicy=i.referrerPolicy),i.crossOrigin==="use-credentials"?a.credentials="include":i.crossOrigin==="anonymous"?a.credentials="omit":a.credentials="same-origin",a}function r(i){if(i.ep)return;i.ep=!0;const a=n(i);fetch(i.href,a)}})();var t_=typeof globalThis<"u"?globalThis:typeof window<"u"?window:typeof global<"u"?global:typeof self<"u"?self:{};function Uo(t){return t&&t.__esModule&&Object.prototype.hasOwnProperty.call(t,"default")?t.default:t}var _T={exports:{}},Qx={};var wU;function Pae(){if(wU)return Qx;wU=1;var t=Symbol.for("react.transitional.element"),e=Symbol.for("react.fragment");function n(r,i,a){var o=null;if(a!==void 0&&(o=""+a),i.key!==void 0&&(o=""+i.key),"key"in i){a={};for(var l in i)l!=="key"&&(a[l]=i[l])}else a=i;return i=a.ref,{$$typeof:t,type:r,key:o,ref:i!==void 0?i:null,props:a}}return Qx.Fragment=e,Qx.jsx=n,Qx.jsxs=n,Qx}var SU;function Rae(){return SU||(SU=1,_T.exports=Pae()),_T.exports}var s=Rae(),wT={exports:{}},yn={};var NU;function Dae(){if(NU)return yn;NU=1;var t=Symbol.for("react.transitional.element"),e=Symbol.for("react.portal"),n=Symbol.for("react.fragment"),r=Symbol.for("react.strict_mode"),i=Symbol.for("react.profiler"),a=Symbol.for("react.consumer"),o=Symbol.for("react.context"),l=Symbol.for("react.forward_ref"),c=Symbol.for("react.suspense"),u=Symbol.for("react.memo"),d=Symbol.for("react.lazy"),f=Symbol.for("react.activity"),m=Symbol.iterator;function g(z){return z===null||typeof z!="object"?null:(z=m&&z[m]||z["@@iterator"],typeof z=="function"?z:null)}var x={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},y=Object.assign,b={};function v(z,q,ae){this.props=z,this.context=q,this.refs=b,this.updater=ae||x}v.prototype.isReactComponent={},v.prototype.setState=function(z,q){if(typeof z!="object"&&typeof z!="function"&&z!=null)throw Error("takes an object of state variables to update or a function which returns an object of state variables.");this.updater.enqueueSetState(this,z,q,"setState")},v.prototype.forceUpdate=function(z){this.updater.enqueueForceUpdate(this,z,"forceUpdate")};function N(){}N.prototype=v.prototype;function S(z,q,ae){this.props=z,this.context=q,this.refs=b,this.updater=ae||x}var E=S.prototype=new N;E.constructor=S,y(E,v.prototype),E.isPureReactComponent=!0;var T=Array.isArray;function C(){}var A={H:null,A:null,T:null,S:null},D=Object.prototype.hasOwnProperty;function k(z,q,ae){var se=ae.ref;return{$$typeof:t,type:z,key:q,ref:se!==void 0?se:null,props:ae}}function R(z,q){return k(z.type,q,z.props)}function F(z){return typeof z=="object"&&z!==null&&z.$$typeof===t}function O(z){var q={"=":"=0",":":"=2"};return"$"+z.replace(/[=:]/g,function(ae){return q[ae]})}var W=/\/+/g;function j(z,q){return typeof z=="object"&&z!==null&&z.key!=null?O(""+z.key):q.toString(36)}function G(z){switch(z.status){case"fulfilled":return z.value;case"rejected":throw z.reason;default:switch(typeof z.status=="string"?z.then(C,C):(z.status="pending",z.then(function(q){z.status==="pending"&&(z.status="fulfilled",z.value=q)},function(q){z.status==="pending"&&(z.status="rejected",z.reason=q)})),z.status){case"fulfilled":return z.value;case"rejected":throw z.reason}}throw z}function Y(z,q,ae,se,de){var pe=typeof z;(pe==="undefined"||pe==="boolean")&&(z=null);var ue=!1;if(z===null)ue=!0;else switch(pe){case"bigint":case"string":case"number":ue=!0;break;case"object":switch(z.$$typeof){case t:case e:ue=!0;break;case d:return ue=z._init,Y(ue(z._payload),q,ae,se,de)}}if(ue)return de=de(z),ue=se===""?"."+j(z,0):se,T(de)?(ae="",ue!=null&&(ae=ue.replace(W,"$&/")+"/"),Y(de,q,ae,"",function(Me){return Me})):de!=null&&(F(de)&&(de=R(de,ae+(de.key==null||z&&z.key===de.key?"":(""+de.key).replace(W,"$&/")+"/")+ue)),q.push(de)),1;ue=0;var Te=se===""?".":se+":";if(T(z))for(var ge=0;ge>>1,te=Y[ee];if(0>>1;eei(ae,Z))sei(de,ae)?(Y[ee]=de,Y[se]=Z,ee=se):(Y[ee]=ae,Y[q]=Z,ee=q);else if(sei(de,Z))Y[ee]=de,Y[se]=Z,ee=se;else break e}}return K}function i(Y,K){var Z=Y.sortIndex-K.sortIndex;return Z!==0?Z:Y.id-K.id}if(t.unstable_now=void 0,typeof performance=="object"&&typeof performance.now=="function"){var a=performance;t.unstable_now=function(){return a.now()}}else{var o=Date,l=o.now();t.unstable_now=function(){return o.now()-l}}var c=[],u=[],d=1,f=null,m=3,g=!1,x=!1,y=!1,b=!1,v=typeof setTimeout=="function"?setTimeout:null,N=typeof clearTimeout=="function"?clearTimeout:null,S=typeof setImmediate<"u"?setImmediate:null;function E(Y){for(var K=n(u);K!==null;){if(K.callback===null)r(u);else if(K.startTime<=Y)r(u),K.sortIndex=K.expirationTime,e(c,K);else break;K=n(u)}}function T(Y){if(y=!1,E(Y),!x)if(n(c)!==null)x=!0,C||(C=!0,O());else{var K=n(u);K!==null&&G(T,K.startTime-Y)}}var C=!1,A=-1,D=5,k=-1;function R(){return b?!0:!(t.unstable_now()-kY&&R());){var ee=f.callback;if(typeof ee=="function"){f.callback=null,m=f.priorityLevel;var te=ee(f.expirationTime<=Y);if(Y=t.unstable_now(),typeof te=="function"){f.callback=te,E(Y),K=!0;break t}f===n(c)&&r(c),E(Y)}else r(c);f=n(c)}if(f!==null)K=!0;else{var z=n(u);z!==null&&G(T,z.startTime-Y),K=!1}}break e}finally{f=null,m=Z,g=!1}K=void 0}}finally{K?O():C=!1}}}var O;if(typeof S=="function")O=function(){S(F)};else if(typeof MessageChannel<"u"){var W=new MessageChannel,j=W.port2;W.port1.onmessage=F,O=function(){j.postMessage(null)}}else O=function(){v(F,0)};function G(Y,K){A=v(function(){Y(t.unstable_now())},K)}t.unstable_IdlePriority=5,t.unstable_ImmediatePriority=1,t.unstable_LowPriority=4,t.unstable_NormalPriority=3,t.unstable_Profiling=null,t.unstable_UserBlockingPriority=2,t.unstable_cancelCallback=function(Y){Y.callback=null},t.unstable_forceFrameRate=function(Y){0>Y||125ee?(Y.sortIndex=Z,e(u,Y),n(c)===null&&Y===n(u)&&(y?(N(A),A=-1):y=!0,G(T,Z-ee))):(Y.sortIndex=te,e(c,Y),x||g||(x=!0,C||(C=!0,O()))),Y},t.unstable_shouldYield=R,t.unstable_wrapCallback=function(Y){var K=m;return function(){var Z=m;m=K;try{return Y.apply(this,arguments)}finally{m=Z}}}})(MT)),MT}var TU;function Iae(){return TU||(TU=1,NT.exports=Lae()),NT.exports}var ET={exports:{}},Aa={};var AU;function Fae(){if(AU)return Aa;AU=1;var t=Lg();function e(c){var u="https://react.dev/errors/"+c;if(1"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(t)}catch(e){console.error(e)}}return t(),ET.exports=Fae(),ET.exports}var kU;function Uae(){if(kU)return Zx;kU=1;var t=Iae(),e=Lg(),n=sW();function r(h){var p="https://react.dev/errors/"+h;if(1te||(h.current=ee[te],ee[te]=null,te--)}function ae(h,p){te++,ee[te]=h.current,h.current=p}var se=z(null),de=z(null),pe=z(null),ue=z(null);function Te(h,p){switch(ae(pe,p),ae(de,h),ae(se,null),p.nodeType){case 9:case 11:h=(h=p.documentElement)&&(h=h.namespaceURI)?GF(h):0;break;default:if(h=p.tagName,p=p.namespaceURI)p=GF(p),h=WF(p,h);else switch(h){case"svg":h=1;break;case"math":h=2;break;default:h=0}}q(se),ae(se,h)}function ge(){q(se),q(de),q(pe)}function Me(h){h.memoizedState!==null&&ae(ue,h);var p=se.current,_=WF(p,h.type);p!==_&&(ae(de,h),ae(se,_))}function we(h){de.current===h&&(q(se),q(de)),ue.current===h&&(q(ue),qx._currentValue=Z)}var ie,Ue;function H(h){if(ie===void 0)try{throw Error()}catch(_){var p=_.stack.trim().match(/\n( *(at )?)/);ie=p&&p[1]||"",Ue=-1<_.stack.indexOf(` + at`)?" ()":-1<_.stack.indexOf("@")?"@unknown:0:0":""}return` +`+ie+h+Ue}var B=!1;function X(h,p){if(!h||B)return"";B=!0;var _=Error.prepareStackTrace;Error.prepareStackTrace=void 0;try{var M={DetermineComponentFrameRoot:function(){try{if(p){var _t=function(){throw Error()};if(Object.defineProperty(_t.prototype,"props",{set:function(){throw Error()}}),typeof Reflect=="object"&&Reflect.construct){try{Reflect.construct(_t,[])}catch(mt){var lt=mt}Reflect.construct(h,[],_t)}else{try{_t.call()}catch(mt){lt=mt}h.call(_t.prototype)}}else{try{throw Error()}catch(mt){lt=mt}(_t=h())&&typeof _t.catch=="function"&&_t.catch(function(){})}}catch(mt){if(mt&<&&typeof mt.stack=="string")return[mt.stack,lt.stack]}return[null,null]}};M.DetermineComponentFrameRoot.displayName="DetermineComponentFrameRoot";var P=Object.getOwnPropertyDescriptor(M.DetermineComponentFrameRoot,"name");P&&P.configurable&&Object.defineProperty(M.DetermineComponentFrameRoot,"name",{value:"DetermineComponentFrameRoot"});var L=M.DetermineComponentFrameRoot(),J=L[0],xe=L[1];if(J&&xe){var Ie=J.split(` +`),rt=xe.split(` +`);for(P=M=0;MP||Ie[M]!==rt[P]){var bt=` +`+Ie[M].replace(" at new "," at ");return h.displayName&&bt.includes("")&&(bt=bt.replace("",h.displayName)),bt}while(1<=M&&0<=P);break}}}finally{B=!1,Error.prepareStackTrace=_}return(_=h?h.displayName||h.name:"")?H(_):""}function U(h,p){switch(h.tag){case 26:case 27:case 5:return H(h.type);case 16:return H("Lazy");case 13:return h.child!==p&&p!==null?H("Suspense Fallback"):H("Suspense");case 19:return H("SuspenseList");case 0:case 15:return X(h.type,!1);case 11:return X(h.type.render,!1);case 1:return X(h.type,!0);case 31:return H("Activity");default:return""}}function Q(h){try{var p="",_=null;do p+=U(h,_),_=h,h=h.return;while(h);return p}catch(M){return` +Error generating stack: `+M.message+` +`+M.stack}}var le=Object.prototype.hasOwnProperty,Ce=t.unstable_scheduleCallback,Pe=t.unstable_cancelCallback,Ae=t.unstable_shouldYield,$=t.unstable_requestPaint,I=t.unstable_now,V=t.unstable_getCurrentPriorityLevel,ne=t.unstable_ImmediatePriority,_e=t.unstable_UserBlockingPriority,be=t.unstable_NormalPriority,$e=t.unstable_LowPriority,Le=t.unstable_IdlePriority,Ke=t.log,We=t.unstable_setDisableYieldValue,Xe=null,oe=null;function ye(h){if(typeof Ke=="function"&&We(h),oe&&typeof oe.setStrictMode=="function")try{oe.setStrictMode(Xe,h)}catch{}}var De=Math.clz32?Math.clz32:re,qe=Math.log,at=Math.LN2;function re(h){return h>>>=0,h===0?32:31-(qe(h)/at|0)|0}var ce=256,fe=262144,ke=4194304;function Oe(h){var p=h&42;if(p!==0)return p;switch(h&-h){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:return 64;case 128:return 128;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:return h&261888;case 262144:case 524288:case 1048576:case 2097152:return h&3932160;case 4194304:case 8388608:case 16777216:case 33554432:return h&62914560;case 67108864:return 67108864;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 0;default:return h}}function Ee(h,p,_){var M=h.pendingLanes;if(M===0)return 0;var P=0,L=h.suspendedLanes,J=h.pingedLanes;h=h.warmLanes;var xe=M&134217727;return xe!==0?(M=xe&~L,M!==0?P=Oe(M):(J&=xe,J!==0?P=Oe(J):_||(_=xe&~h,_!==0&&(P=Oe(_))))):(xe=M&~L,xe!==0?P=Oe(xe):J!==0?P=Oe(J):_||(_=M&~h,_!==0&&(P=Oe(_)))),P===0?0:p!==0&&p!==P&&(p&L)===0&&(L=P&-P,_=p&-p,L>=_||L===32&&(_&4194048)!==0)?p:P}function Je(h,p){return(h.pendingLanes&~(h.suspendedLanes&~h.pingedLanes)&p)===0}function xt(h,p){switch(h){case 1:case 2:case 4:case 8:case 64:return p+250;case 16:case 32:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return p+5e3;case 4194304:case 8388608:case 16777216:case 33554432:return-1;case 67108864:case 134217728:case 268435456:case 536870912:case 1073741824:return-1;default:return-1}}function Rt(){var h=ke;return ke<<=1,(ke&62914560)===0&&(ke=4194304),h}function kt(h){for(var p=[],_=0;31>_;_++)p.push(h);return p}function on(h,p){h.pendingLanes|=p,p!==268435456&&(h.suspendedLanes=0,h.pingedLanes=0,h.warmLanes=0)}function Bt(h,p,_,M,P,L){var J=h.pendingLanes;h.pendingLanes=_,h.suspendedLanes=0,h.pingedLanes=0,h.warmLanes=0,h.expiredLanes&=_,h.entangledLanes&=_,h.errorRecoveryDisabledLanes&=_,h.shellSuspendCounter=0;var xe=h.entanglements,Ie=h.expirationTimes,rt=h.hiddenUpdates;for(_=J&~_;0<_;){var bt=31-De(_),_t=1<"u")return null;try{return h.activeElement||h.body}catch{return h.body}}var Xt=/[\n"\\]/g;function ot(h){return h.replace(Xt,function(p){return"\\"+p.charCodeAt(0).toString(16)+" "})}function bn(h,p,_,M,P,L,J,xe){h.name="",J!=null&&typeof J!="function"&&typeof J!="symbol"&&typeof J!="boolean"?h.type=J:h.removeAttribute("type"),p!=null?J==="number"?(p===0&&h.value===""||h.value!=p)&&(h.value=""+ln(p)):h.value!==""+ln(p)&&(h.value=""+ln(p)):J!=="submit"&&J!=="reset"||h.removeAttribute("value"),p!=null?jn(h,J,ln(p)):_!=null?jn(h,J,ln(_)):M!=null&&h.removeAttribute("value"),P==null&&L!=null&&(h.defaultChecked=!!L),P!=null&&(h.checked=P&&typeof P!="function"&&typeof P!="symbol"),xe!=null&&typeof xe!="function"&&typeof xe!="symbol"&&typeof xe!="boolean"?h.name=""+ln(xe):h.removeAttribute("name")}function cn(h,p,_,M,P,L,J,xe){if(L!=null&&typeof L!="function"&&typeof L!="symbol"&&typeof L!="boolean"&&(h.type=L),p!=null||_!=null){if(!(L!=="submit"&&L!=="reset"||p!=null)){Vt(h);return}_=_!=null?""+ln(_):"",p=p!=null?""+ln(p):_,xe||p===h.value||(h.value=p),h.defaultValue=p}M=M??P,M=typeof M!="function"&&typeof M!="symbol"&&!!M,h.checked=xe?h.checked:!!M,h.defaultChecked=!!M,J!=null&&typeof J!="function"&&typeof J!="symbol"&&typeof J!="boolean"&&(h.name=J),Vt(h)}function jn(h,p,_){p==="number"&&At(h.ownerDocument)===h||h.defaultValue===""+_||(h.defaultValue=""+_)}function Fn(h,p,_,M){if(h=h.options,p){p={};for(var P=0;P<_.length;P++)p["$"+_[P]]=!0;for(_=0;_"u"||typeof window.document>"u"||typeof window.document.createElement>"u"),_M=!1;if(ic)try{var dx={};Object.defineProperty(dx,"passive",{get:function(){_M=!0}}),window.addEventListener("test",dx,dx),window.removeEventListener("test",dx,dx)}catch{_M=!1}var Mu=null,wM=null,qy=null;function WL(){if(qy)return qy;var h,p=wM,_=p.length,M,P="value"in Mu?Mu.value:Mu.textContent,L=P.length;for(h=0;h<_&&p[h]===P[h];h++);var J=_-h;for(M=1;M<=J&&p[_-M]===P[L-M];M++);return qy=P.slice(h,1=mx),ZL=" ",JL=!1;function eI(h,p){switch(h){case"keyup":return Jre.indexOf(p.keyCode)!==-1;case"keydown":return p.keyCode!==229;case"keypress":case"mousedown":case"focusout":return!0;default:return!1}}function tI(h){return h=h.detail,typeof h=="object"&&"data"in h?h.data:null}var um=!1;function tie(h,p){switch(h){case"compositionend":return tI(p);case"keypress":return p.which!==32?null:(JL=!0,ZL);case"textInput":return h=p.data,h===ZL&&JL?null:h;default:return null}}function nie(h,p){if(um)return h==="compositionend"||!TM&&eI(h,p)?(h=WL(),qy=wM=Mu=null,um=!1,h):null;switch(h){case"paste":return null;case"keypress":if(!(p.ctrlKey||p.altKey||p.metaKey)||p.ctrlKey&&p.altKey){if(p.char&&1=p)return{node:_,offset:p-h};h=M}e:{for(;_;){if(_.nextSibling){_=_.nextSibling;break e}_=_.parentNode}_=void 0}_=cI(_)}}function dI(h,p){return h&&p?h===p?!0:h&&h.nodeType===3?!1:p&&p.nodeType===3?dI(h,p.parentNode):"contains"in h?h.contains(p):h.compareDocumentPosition?!!(h.compareDocumentPosition(p)&16):!1:!1}function hI(h){h=h!=null&&h.ownerDocument!=null&&h.ownerDocument.defaultView!=null?h.ownerDocument.defaultView:window;for(var p=At(h.document);p instanceof h.HTMLIFrameElement;){try{var _=typeof p.contentWindow.location.href=="string"}catch{_=!1}if(_)h=p.contentWindow;else break;p=At(h.document)}return p}function kM(h){var p=h&&h.nodeName&&h.nodeName.toLowerCase();return p&&(p==="input"&&(h.type==="text"||h.type==="search"||h.type==="tel"||h.type==="url"||h.type==="password")||p==="textarea"||h.contentEditable==="true")}var uie=ic&&"documentMode"in document&&11>=document.documentMode,dm=null,jM=null,bx=null,PM=!1;function fI(h,p,_){var M=_.window===_?_.document:_.nodeType===9?_:_.ownerDocument;PM||dm==null||dm!==At(M)||(M=dm,"selectionStart"in M&&kM(M)?M={start:M.selectionStart,end:M.selectionEnd}:(M=(M.ownerDocument&&M.ownerDocument.defaultView||window).getSelection(),M={anchorNode:M.anchorNode,anchorOffset:M.anchorOffset,focusNode:M.focusNode,focusOffset:M.focusOffset}),bx&&xx(bx,M)||(bx=M,M=zv(jM,"onSelect"),0>=J,P-=J,gl=1<<32-De(p)+P|_<wn?(zn=Kt,Kt=null):zn=Kt.sibling;var Yn=lt(Ze,Kt,nt[wn],vt);if(Yn===null){Kt===null&&(Kt=zn);break}h&&Kt&&Yn.alternate===null&&p(Ze,Kt),He=L(Yn,He,wn),Xn===null?Qt=Yn:Xn.sibling=Yn,Xn=Yn,Kt=zn}if(wn===nt.length)return _(Ze,Kt),$n&&sc(Ze,wn),Qt;if(Kt===null){for(;wnwn?(zn=Kt,Kt=null):zn=Kt.sibling;var qu=lt(Ze,Kt,Yn.value,vt);if(qu===null){Kt===null&&(Kt=zn);break}h&&Kt&&qu.alternate===null&&p(Ze,Kt),He=L(qu,He,wn),Xn===null?Qt=qu:Xn.sibling=qu,Xn=qu,Kt=zn}if(Yn.done)return _(Ze,Kt),$n&&sc(Ze,wn),Qt;if(Kt===null){for(;!Yn.done;wn++,Yn=nt.next())Yn=_t(Ze,Yn.value,vt),Yn!==null&&(He=L(Yn,He,wn),Xn===null?Qt=Yn:Xn.sibling=Yn,Xn=Yn);return $n&&sc(Ze,wn),Qt}for(Kt=M(Kt);!Yn.done;wn++,Yn=nt.next())Yn=mt(Kt,Ze,wn,Yn.value,vt),Yn!==null&&(h&&Yn.alternate!==null&&Kt.delete(Yn.key===null?wn:Yn.key),He=L(Yn,He,wn),Xn===null?Qt=Yn:Xn.sibling=Yn,Xn=Yn);return h&&Kt.forEach(function(kae){return p(Ze,kae)}),$n&&sc(Ze,wn),Qt}function fr(Ze,He,nt,vt){if(typeof nt=="object"&&nt!==null&&nt.type===y&&nt.key===null&&(nt=nt.props.children),typeof nt=="object"&&nt!==null){switch(nt.$$typeof){case g:e:{for(var Qt=nt.key;He!==null;){if(He.key===Qt){if(Qt=nt.type,Qt===y){if(He.tag===7){_(Ze,He.sibling),vt=P(He,nt.props.children),vt.return=Ze,Ze=vt;break e}}else if(He.elementType===Qt||typeof Qt=="object"&&Qt!==null&&Qt.$$typeof===D&&Eh(Qt)===He.type){_(Ze,He.sibling),vt=P(He,nt.props),Nx(vt,nt),vt.return=Ze,Ze=vt;break e}_(Ze,He);break}else p(Ze,He);He=He.sibling}nt.type===y?(vt=_h(nt.props.children,Ze.mode,vt,nt.key),vt.return=Ze,Ze=vt):(vt=rv(nt.type,nt.key,nt.props,null,Ze.mode,vt),Nx(vt,nt),vt.return=Ze,Ze=vt)}return J(Ze);case x:e:{for(Qt=nt.key;He!==null;){if(He.key===Qt)if(He.tag===4&&He.stateNode.containerInfo===nt.containerInfo&&He.stateNode.implementation===nt.implementation){_(Ze,He.sibling),vt=P(He,nt.children||[]),vt.return=Ze,Ze=vt;break e}else{_(Ze,He);break}else p(Ze,He);He=He.sibling}vt=UM(nt,Ze.mode,vt),vt.return=Ze,Ze=vt}return J(Ze);case D:return nt=Eh(nt),fr(Ze,He,nt,vt)}if(G(nt))return Wt(Ze,He,nt,vt);if(O(nt)){if(Qt=O(nt),typeof Qt!="function")throw Error(r(150));return nt=Qt.call(nt),an(Ze,He,nt,vt)}if(typeof nt.then=="function")return fr(Ze,He,uv(nt),vt);if(nt.$$typeof===S)return fr(Ze,He,sv(Ze,nt),vt);dv(Ze,nt)}return typeof nt=="string"&&nt!==""||typeof nt=="number"||typeof nt=="bigint"?(nt=""+nt,He!==null&&He.tag===6?(_(Ze,He.sibling),vt=P(He,nt),vt.return=Ze,Ze=vt):(_(Ze,He),vt=FM(nt,Ze.mode,vt),vt.return=Ze,Ze=vt),J(Ze)):_(Ze,He)}return function(Ze,He,nt,vt){try{Sx=0;var Qt=fr(Ze,He,nt,vt);return wm=null,Qt}catch(Kt){if(Kt===_m||Kt===lv)throw Kt;var Xn=js(29,Kt,null,Ze.mode);return Xn.lanes=vt,Xn.return=Ze,Xn}finally{}}}var Ah=LI(!0),II=LI(!1),ku=!1;function QM(h){h.updateQueue={baseState:h.memoizedState,firstBaseUpdate:null,lastBaseUpdate:null,shared:{pending:null,lanes:0,hiddenCallbacks:null},callbacks:null}}function ZM(h,p){h=h.updateQueue,p.updateQueue===h&&(p.updateQueue={baseState:h.baseState,firstBaseUpdate:h.firstBaseUpdate,lastBaseUpdate:h.lastBaseUpdate,shared:h.shared,callbacks:null})}function ju(h){return{lane:h,tag:0,payload:null,callback:null,next:null}}function Pu(h,p,_){var M=h.updateQueue;if(M===null)return null;if(M=M.shared,(er&2)!==0){var P=M.pending;return P===null?p.next=p:(p.next=P.next,P.next=p),M.pending=p,p=nv(h),vI(h,null,_),p}return tv(h,M,p,_),nv(h)}function Mx(h,p,_){if(p=p.updateQueue,p!==null&&(p=p.shared,(_&4194048)!==0)){var M=p.lanes;M&=h.pendingLanes,_|=M,p.lanes=_,fn(h,_)}}function JM(h,p){var _=h.updateQueue,M=h.alternate;if(M!==null&&(M=M.updateQueue,_===M)){var P=null,L=null;if(_=_.firstBaseUpdate,_!==null){do{var J={lane:_.lane,tag:_.tag,payload:_.payload,callback:null,next:null};L===null?P=L=J:L=L.next=J,_=_.next}while(_!==null);L===null?P=L=p:L=L.next=p}else P=L=p;_={baseState:M.baseState,firstBaseUpdate:P,lastBaseUpdate:L,shared:M.shared,callbacks:M.callbacks},h.updateQueue=_;return}h=_.lastBaseUpdate,h===null?_.firstBaseUpdate=p:h.next=p,_.lastBaseUpdate=p}var eE=!1;function Ex(){if(eE){var h=vm;if(h!==null)throw h}}function Tx(h,p,_,M){eE=!1;var P=h.updateQueue;ku=!1;var L=P.firstBaseUpdate,J=P.lastBaseUpdate,xe=P.shared.pending;if(xe!==null){P.shared.pending=null;var Ie=xe,rt=Ie.next;Ie.next=null,J===null?L=rt:J.next=rt,J=Ie;var bt=h.alternate;bt!==null&&(bt=bt.updateQueue,xe=bt.lastBaseUpdate,xe!==J&&(xe===null?bt.firstBaseUpdate=rt:xe.next=rt,bt.lastBaseUpdate=Ie))}if(L!==null){var _t=P.baseState;J=0,bt=rt=Ie=null,xe=L;do{var lt=xe.lane&-536870913,mt=lt!==xe.lane;if(mt?(Un<)===lt:(M<)===lt){lt!==0&<===ym&&(eE=!0),bt!==null&&(bt=bt.next={lane:0,tag:xe.tag,payload:xe.payload,callback:null,next:null});e:{var Wt=h,an=xe;lt=p;var fr=_;switch(an.tag){case 1:if(Wt=an.payload,typeof Wt=="function"){_t=Wt.call(fr,_t,lt);break e}_t=Wt;break e;case 3:Wt.flags=Wt.flags&-65537|128;case 0:if(Wt=an.payload,lt=typeof Wt=="function"?Wt.call(fr,_t,lt):Wt,lt==null)break e;_t=f({},_t,lt);break e;case 2:ku=!0}}lt=xe.callback,lt!==null&&(h.flags|=64,mt&&(h.flags|=8192),mt=P.callbacks,mt===null?P.callbacks=[lt]:mt.push(lt))}else mt={lane:lt,tag:xe.tag,payload:xe.payload,callback:xe.callback,next:null},bt===null?(rt=bt=mt,Ie=_t):bt=bt.next=mt,J|=lt;if(xe=xe.next,xe===null){if(xe=P.shared.pending,xe===null)break;mt=xe,xe=mt.next,mt.next=null,P.lastBaseUpdate=mt,P.shared.pending=null}}while(!0);bt===null&&(Ie=_t),P.baseState=Ie,P.firstBaseUpdate=rt,P.lastBaseUpdate=bt,L===null&&(P.shared.lanes=0),Iu|=J,h.lanes=J,h.memoizedState=_t}}function FI(h,p){if(typeof h!="function")throw Error(r(191,h));h.call(p)}function UI(h,p){var _=h.callbacks;if(_!==null)for(h.callbacks=null,h=0;h<_.length;h++)FI(_[h],p)}var Sm=z(null),hv=z(0);function zI(h,p){h=xc,ae(hv,h),ae(Sm,p),xc=h|p.baseLanes}function tE(){ae(hv,xc),ae(Sm,Sm.current)}function nE(){xc=hv.current,q(Sm),q(hv)}var Ps=z(null),oo=null;function Ru(h){var p=h.alternate;ae(ci,ci.current&1),ae(Ps,h),oo===null&&(p===null||Sm.current!==null||p.memoizedState!==null)&&(oo=h)}function rE(h){ae(ci,ci.current),ae(Ps,h),oo===null&&(oo=h)}function BI(h){h.tag===22?(ae(ci,ci.current),ae(Ps,h),oo===null&&(oo=h)):Du()}function Du(){ae(ci,ci.current),ae(Ps,Ps.current)}function Rs(h){q(Ps),oo===h&&(oo=null),q(ci)}var ci=z(0);function fv(h){for(var p=h;p!==null;){if(p.tag===13){var _=p.memoizedState;if(_!==null&&(_=_.dehydrated,_===null||cT(_)||uT(_)))return p}else if(p.tag===19&&(p.memoizedProps.revealOrder==="forwards"||p.memoizedProps.revealOrder==="backwards"||p.memoizedProps.revealOrder==="unstable_legacy-backwards"||p.memoizedProps.revealOrder==="together")){if((p.flags&128)!==0)return p}else if(p.child!==null){p.child.return=p,p=p.child;continue}if(p===h)break;for(;p.sibling===null;){if(p.return===null||p.return===h)return null;p=p.return}p.sibling.return=p.return,p=p.sibling}return null}var cc=0,vn=null,dr=null,gi=null,mv=!1,Nm=!1,Ch=!1,pv=0,Ax=0,Mm=null,vie=0;function Jr(){throw Error(r(321))}function iE(h,p){if(p===null)return!1;for(var _=0;_L?L:8;var J=Y.T,xe={};Y.T=xe,yE(h,!1,p,_);try{var Ie=P(),rt=Y.S;if(rt!==null&&rt(xe,Ie),Ie!==null&&typeof Ie=="object"&&typeof Ie.then=="function"){var bt=yie(Ie,M);kx(h,p,bt,Ls(h))}else kx(h,p,M,Ls(h))}catch(_t){kx(h,p,{then:function(){},status:"rejected",reason:_t},Ls())}finally{K.p=L,J!==null&&xe.types!==null&&(J.types=xe.types),Y.T=J}}function Mie(){}function xE(h,p,_,M){if(h.tag!==5)throw Error(r(476));var P=x5(h).queue;g5(h,P,p,Z,_===null?Mie:function(){return b5(h),_(M)})}function x5(h){var p=h.memoizedState;if(p!==null)return p;p={memoizedState:Z,baseState:Z,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:uc,lastRenderedState:Z},next:null};var _={};return p.next={memoizedState:_,baseState:_,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:uc,lastRenderedState:_},next:null},h.memoizedState=p,h=h.alternate,h!==null&&(h.memoizedState=p),p}function b5(h){var p=x5(h);p.next===null&&(p=h.alternate.memoizedState),kx(h,p.next.queue,{},Ls())}function bE(){return ha(qx)}function y5(){return ui().memoizedState}function v5(){return ui().memoizedState}function Eie(h){for(var p=h.return;p!==null;){switch(p.tag){case 24:case 3:var _=Ls();h=ju(_);var M=Pu(p,h,_);M!==null&&(hs(M,p,_),Mx(M,p,_)),p={cache:qM()},h.payload=p;return}p=p.return}}function Tie(h,p,_){var M=Ls();_={lane:M,revertLane:0,gesture:null,action:_,hasEagerState:!1,eagerState:null,next:null},_v(h)?w5(p,_):(_=LM(h,p,_,M),_!==null&&(hs(_,h,M),S5(_,p,M)))}function _5(h,p,_){var M=Ls();kx(h,p,_,M)}function kx(h,p,_,M){var P={lane:M,revertLane:0,gesture:null,action:_,hasEagerState:!1,eagerState:null,next:null};if(_v(h))w5(p,P);else{var L=h.alternate;if(h.lanes===0&&(L===null||L.lanes===0)&&(L=p.lastRenderedReducer,L!==null))try{var J=p.lastRenderedState,xe=L(J,_);if(P.hasEagerState=!0,P.eagerState=xe,ks(xe,J))return tv(h,p,P,0),Sr===null&&ev(),!1}catch{}finally{}if(_=LM(h,p,P,M),_!==null)return hs(_,h,M),S5(_,p,M),!0}return!1}function yE(h,p,_,M){if(M={lane:2,revertLane:QE(),gesture:null,action:M,hasEagerState:!1,eagerState:null,next:null},_v(h)){if(p)throw Error(r(479))}else p=LM(h,_,M,2),p!==null&&hs(p,h,2)}function _v(h){var p=h.alternate;return h===vn||p!==null&&p===vn}function w5(h,p){Nm=mv=!0;var _=h.pending;_===null?p.next=p:(p.next=_.next,_.next=p),h.pending=p}function S5(h,p,_){if((_&4194048)!==0){var M=p.lanes;M&=h.pendingLanes,_|=M,p.lanes=_,fn(h,_)}}var jx={readContext:ha,use:xv,useCallback:Jr,useContext:Jr,useEffect:Jr,useImperativeHandle:Jr,useLayoutEffect:Jr,useInsertionEffect:Jr,useMemo:Jr,useReducer:Jr,useRef:Jr,useState:Jr,useDebugValue:Jr,useDeferredValue:Jr,useTransition:Jr,useSyncExternalStore:Jr,useId:Jr,useHostTransitionStatus:Jr,useFormState:Jr,useActionState:Jr,useOptimistic:Jr,useMemoCache:Jr,useCacheRefresh:Jr};jx.useEffectEvent=Jr;var N5={readContext:ha,use:xv,useCallback:function(h,p){return Ha().memoizedState=[h,p===void 0?null:p],h},useContext:ha,useEffect:o5,useImperativeHandle:function(h,p,_){_=_!=null?_.concat([h]):null,yv(4194308,4,d5.bind(null,p,h),_)},useLayoutEffect:function(h,p){return yv(4194308,4,h,p)},useInsertionEffect:function(h,p){yv(4,2,h,p)},useMemo:function(h,p){var _=Ha();p=p===void 0?null:p;var M=h();if(Ch){ye(!0);try{h()}finally{ye(!1)}}return _.memoizedState=[M,p],M},useReducer:function(h,p,_){var M=Ha();if(_!==void 0){var P=_(p);if(Ch){ye(!0);try{_(p)}finally{ye(!1)}}}else P=p;return M.memoizedState=M.baseState=P,h={pending:null,lanes:0,dispatch:null,lastRenderedReducer:h,lastRenderedState:P},M.queue=h,h=h.dispatch=Tie.bind(null,vn,h),[M.memoizedState,h]},useRef:function(h){var p=Ha();return h={current:h},p.memoizedState=h},useState:function(h){h=hE(h);var p=h.queue,_=_5.bind(null,vn,p);return p.dispatch=_,[h.memoizedState,_]},useDebugValue:pE,useDeferredValue:function(h,p){var _=Ha();return gE(_,h,p)},useTransition:function(){var h=hE(!1);return h=g5.bind(null,vn,h.queue,!0,!1),Ha().memoizedState=h,[!1,h]},useSyncExternalStore:function(h,p,_){var M=vn,P=Ha();if($n){if(_===void 0)throw Error(r(407));_=_()}else{if(_=p(),Sr===null)throw Error(r(349));(Un&127)!==0||GI(M,p,_)}P.memoizedState=_;var L={value:_,getSnapshot:p};return P.queue=L,o5(qI.bind(null,M,L,h),[h]),M.flags|=2048,Em(9,{destroy:void 0},WI.bind(null,M,L,_,p),null),_},useId:function(){var h=Ha(),p=Sr.identifierPrefix;if($n){var _=xl,M=gl;_=(M&~(1<<32-De(M)-1)).toString(32)+_,p="_"+p+"R_"+_,_=pv++,0<_&&(p+="H"+_.toString(32)),p+="_"}else _=vie++,p="_"+p+"r_"+_.toString(32)+"_";return h.memoizedState=p},useHostTransitionStatus:bE,useFormState:n5,useActionState:n5,useOptimistic:function(h){var p=Ha();p.memoizedState=p.baseState=h;var _={pending:null,lanes:0,dispatch:null,lastRenderedReducer:null,lastRenderedState:null};return p.queue=_,p=yE.bind(null,vn,!0,_),_.dispatch=p,[h,p]},useMemoCache:cE,useCacheRefresh:function(){return Ha().memoizedState=Eie.bind(null,vn)},useEffectEvent:function(h){var p=Ha(),_={impl:h};return p.memoizedState=_,function(){if((er&2)!==0)throw Error(r(440));return _.impl.apply(void 0,arguments)}}},vE={readContext:ha,use:xv,useCallback:f5,useContext:ha,useEffect:mE,useImperativeHandle:h5,useInsertionEffect:c5,useLayoutEffect:u5,useMemo:m5,useReducer:bv,useRef:s5,useState:function(){return bv(uc)},useDebugValue:pE,useDeferredValue:function(h,p){var _=ui();return p5(_,dr.memoizedState,h,p)},useTransition:function(){var h=bv(uc)[0],p=ui().memoizedState;return[typeof h=="boolean"?h:Cx(h),p]},useSyncExternalStore:VI,useId:y5,useHostTransitionStatus:bE,useFormState:r5,useActionState:r5,useOptimistic:function(h,p){var _=ui();return YI(_,dr,h,p)},useMemoCache:cE,useCacheRefresh:v5};vE.useEffectEvent=l5;var M5={readContext:ha,use:xv,useCallback:f5,useContext:ha,useEffect:mE,useImperativeHandle:h5,useInsertionEffect:c5,useLayoutEffect:u5,useMemo:m5,useReducer:dE,useRef:s5,useState:function(){return dE(uc)},useDebugValue:pE,useDeferredValue:function(h,p){var _=ui();return dr===null?gE(_,h,p):p5(_,dr.memoizedState,h,p)},useTransition:function(){var h=dE(uc)[0],p=ui().memoizedState;return[typeof h=="boolean"?h:Cx(h),p]},useSyncExternalStore:VI,useId:y5,useHostTransitionStatus:bE,useFormState:a5,useActionState:a5,useOptimistic:function(h,p){var _=ui();return dr!==null?YI(_,dr,h,p):(_.baseState=h,[h,_.queue.dispatch])},useMemoCache:cE,useCacheRefresh:v5};M5.useEffectEvent=l5;function _E(h,p,_,M){p=h.memoizedState,_=_(M,p),_=_==null?p:f({},p,_),h.memoizedState=_,h.lanes===0&&(h.updateQueue.baseState=_)}var wE={enqueueSetState:function(h,p,_){h=h._reactInternals;var M=Ls(),P=ju(M);P.payload=p,_!=null&&(P.callback=_),p=Pu(h,P,M),p!==null&&(hs(p,h,M),Mx(p,h,M))},enqueueReplaceState:function(h,p,_){h=h._reactInternals;var M=Ls(),P=ju(M);P.tag=1,P.payload=p,_!=null&&(P.callback=_),p=Pu(h,P,M),p!==null&&(hs(p,h,M),Mx(p,h,M))},enqueueForceUpdate:function(h,p){h=h._reactInternals;var _=Ls(),M=ju(_);M.tag=2,p!=null&&(M.callback=p),p=Pu(h,M,_),p!==null&&(hs(p,h,_),Mx(p,h,_))}};function E5(h,p,_,M,P,L,J){return h=h.stateNode,typeof h.shouldComponentUpdate=="function"?h.shouldComponentUpdate(M,L,J):p.prototype&&p.prototype.isPureReactComponent?!xx(_,M)||!xx(P,L):!0}function T5(h,p,_,M){h=p.state,typeof p.componentWillReceiveProps=="function"&&p.componentWillReceiveProps(_,M),typeof p.UNSAFE_componentWillReceiveProps=="function"&&p.UNSAFE_componentWillReceiveProps(_,M),p.state!==h&&wE.enqueueReplaceState(p,p.state,null)}function kh(h,p){var _=p;if("ref"in p){_={};for(var M in p)M!=="ref"&&(_[M]=p[M])}if(h=h.defaultProps){_===p&&(_=f({},_));for(var P in h)_[P]===void 0&&(_[P]=h[P])}return _}function A5(h){Jy(h)}function C5(h){console.error(h)}function k5(h){Jy(h)}function wv(h,p){try{var _=h.onUncaughtError;_(p.value,{componentStack:p.stack})}catch(M){setTimeout(function(){throw M})}}function j5(h,p,_){try{var M=h.onCaughtError;M(_.value,{componentStack:_.stack,errorBoundary:p.tag===1?p.stateNode:null})}catch(P){setTimeout(function(){throw P})}}function SE(h,p,_){return _=ju(_),_.tag=3,_.payload={element:null},_.callback=function(){wv(h,p)},_}function P5(h){return h=ju(h),h.tag=3,h}function R5(h,p,_,M){var P=_.type.getDerivedStateFromError;if(typeof P=="function"){var L=M.value;h.payload=function(){return P(L)},h.callback=function(){j5(p,_,M)}}var J=_.stateNode;J!==null&&typeof J.componentDidCatch=="function"&&(h.callback=function(){j5(p,_,M),typeof P!="function"&&(Fu===null?Fu=new Set([this]):Fu.add(this));var xe=M.stack;this.componentDidCatch(M.value,{componentStack:xe!==null?xe:""})})}function Aie(h,p,_,M,P){if(_.flags|=32768,M!==null&&typeof M=="object"&&typeof M.then=="function"){if(p=_.alternate,p!==null&&bm(p,_,P,!0),_=Ps.current,_!==null){switch(_.tag){case 31:case 13:return oo===null?Dv():_.alternate===null&&ei===0&&(ei=3),_.flags&=-257,_.flags|=65536,_.lanes=P,M===cv?_.flags|=16384:(p=_.updateQueue,p===null?_.updateQueue=new Set([M]):p.add(M),KE(h,M,P)),!1;case 22:return _.flags|=65536,M===cv?_.flags|=16384:(p=_.updateQueue,p===null?(p={transitions:null,markerInstances:null,retryQueue:new Set([M])},_.updateQueue=p):(_=p.retryQueue,_===null?p.retryQueue=new Set([M]):_.add(M)),KE(h,M,P)),!1}throw Error(r(435,_.tag))}return KE(h,M,P),Dv(),!1}if($n)return p=Ps.current,p!==null?((p.flags&65536)===0&&(p.flags|=256),p.flags|=65536,p.lanes=P,M!==$M&&(h=Error(r(422),{cause:M}),vx(ro(h,_)))):(M!==$M&&(p=Error(r(423),{cause:M}),vx(ro(p,_))),h=h.current.alternate,h.flags|=65536,P&=-P,h.lanes|=P,M=ro(M,_),P=SE(h.stateNode,M,P),JM(h,P),ei!==4&&(ei=2)),!1;var L=Error(r(520),{cause:M});if(L=ro(L,_),Ux===null?Ux=[L]:Ux.push(L),ei!==4&&(ei=2),p===null)return!0;M=ro(M,_),_=p;do{switch(_.tag){case 3:return _.flags|=65536,h=P&-P,_.lanes|=h,h=SE(_.stateNode,M,h),JM(_,h),!1;case 1:if(p=_.type,L=_.stateNode,(_.flags&128)===0&&(typeof p.getDerivedStateFromError=="function"||L!==null&&typeof L.componentDidCatch=="function"&&(Fu===null||!Fu.has(L))))return _.flags|=65536,P&=-P,_.lanes|=P,P=P5(P),R5(P,h,_,M),JM(_,P),!1}_=_.return}while(_!==null);return!1}var NE=Error(r(461)),xi=!1;function fa(h,p,_,M){p.child=h===null?II(p,null,_,M):Ah(p,h.child,_,M)}function D5(h,p,_,M,P){_=_.render;var L=p.ref;if("ref"in M){var J={};for(var xe in M)xe!=="ref"&&(J[xe]=M[xe])}else J=M;return Nh(p),M=aE(h,p,_,J,L,P),xe=sE(),h!==null&&!xi?(oE(h,p,P),dc(h,p,P)):($n&&xe&&zM(p),p.flags|=1,fa(h,p,M,P),p.child)}function O5(h,p,_,M,P){if(h===null){var L=_.type;return typeof L=="function"&&!IM(L)&&L.defaultProps===void 0&&_.compare===null?(p.tag=15,p.type=L,L5(h,p,L,M,P)):(h=rv(_.type,null,M,p,p.mode,P),h.ref=p.ref,h.return=p,p.child=h)}if(L=h.child,!PE(h,P)){var J=L.memoizedProps;if(_=_.compare,_=_!==null?_:xx,_(J,M)&&h.ref===p.ref)return dc(h,p,P)}return p.flags|=1,h=ac(L,M),h.ref=p.ref,h.return=p,p.child=h}function L5(h,p,_,M,P){if(h!==null){var L=h.memoizedProps;if(xx(L,M)&&h.ref===p.ref)if(xi=!1,p.pendingProps=M=L,PE(h,P))(h.flags&131072)!==0&&(xi=!0);else return p.lanes=h.lanes,dc(h,p,P)}return ME(h,p,_,M,P)}function I5(h,p,_,M){var P=M.children,L=h!==null?h.memoizedState:null;if(h===null&&p.stateNode===null&&(p.stateNode={_visibility:1,_pendingMarkers:null,_retryCache:null,_transitions:null}),M.mode==="hidden"){if((p.flags&128)!==0){if(L=L!==null?L.baseLanes|_:_,h!==null){for(M=p.child=h.child,P=0;M!==null;)P=P|M.lanes|M.childLanes,M=M.sibling;M=P&~L}else M=0,p.child=null;return F5(h,p,L,_,M)}if((_&536870912)!==0)p.memoizedState={baseLanes:0,cachePool:null},h!==null&&ov(p,L!==null?L.cachePool:null),L!==null?zI(p,L):tE(),BI(p);else return M=p.lanes=536870912,F5(h,p,L!==null?L.baseLanes|_:_,_,M)}else L!==null?(ov(p,L.cachePool),zI(p,L),Du(),p.memoizedState=null):(h!==null&&ov(p,null),tE(),Du());return fa(h,p,P,_),p.child}function Px(h,p){return h!==null&&h.tag===22||p.stateNode!==null||(p.stateNode={_visibility:1,_pendingMarkers:null,_retryCache:null,_transitions:null}),p.sibling}function F5(h,p,_,M,P){var L=XM();return L=L===null?null:{parent:pi._currentValue,pool:L},p.memoizedState={baseLanes:_,cachePool:L},h!==null&&ov(p,null),tE(),BI(p),h!==null&&bm(h,p,M,!0),p.childLanes=P,null}function Sv(h,p){return p=Mv({mode:p.mode,children:p.children},h.mode),p.ref=h.ref,h.child=p,p.return=h,p}function U5(h,p,_){return Ah(p,h.child,null,_),h=Sv(p,p.pendingProps),h.flags|=2,Rs(p),p.memoizedState=null,h}function Cie(h,p,_){var M=p.pendingProps,P=(p.flags&128)!==0;if(p.flags&=-129,h===null){if($n){if(M.mode==="hidden")return h=Sv(p,M),p.lanes=536870912,Px(null,h);if(rE(p),(h=jr)?(h=QF(h,so),h=h!==null&&h.data==="&"?h:null,h!==null&&(p.memoizedState={dehydrated:h,treeContext:Eu!==null?{id:gl,overflow:xl}:null,retryLane:536870912,hydrationErrors:null},_=wI(h),_.return=p,p.child=_,da=p,jr=null)):h=null,h===null)throw Au(p);return p.lanes=536870912,null}return Sv(p,M)}var L=h.memoizedState;if(L!==null){var J=L.dehydrated;if(rE(p),P)if(p.flags&256)p.flags&=-257,p=U5(h,p,_);else if(p.memoizedState!==null)p.child=h.child,p.flags|=128,p=null;else throw Error(r(558));else if(xi||bm(h,p,_,!1),P=(_&h.childLanes)!==0,xi||P){if(M=Sr,M!==null&&(J=Vn(M,_),J!==0&&J!==L.retryLane))throw L.retryLane=J,vh(h,J),hs(M,h,J),NE;Dv(),p=U5(h,p,_)}else h=L.treeContext,jr=lo(J.nextSibling),da=p,$n=!0,Tu=null,so=!1,h!==null&&MI(p,h),p=Sv(p,M),p.flags|=4096;return p}return h=ac(h.child,{mode:M.mode,children:M.children}),h.ref=p.ref,p.child=h,h.return=p,h}function Nv(h,p){var _=p.ref;if(_===null)h!==null&&h.ref!==null&&(p.flags|=4194816);else{if(typeof _!="function"&&typeof _!="object")throw Error(r(284));(h===null||h.ref!==_)&&(p.flags|=4194816)}}function ME(h,p,_,M,P){return Nh(p),_=aE(h,p,_,M,void 0,P),M=sE(),h!==null&&!xi?(oE(h,p,P),dc(h,p,P)):($n&&M&&zM(p),p.flags|=1,fa(h,p,_,P),p.child)}function z5(h,p,_,M,P,L){return Nh(p),p.updateQueue=null,_=HI(p,M,_,P),$I(h),M=sE(),h!==null&&!xi?(oE(h,p,L),dc(h,p,L)):($n&&M&&zM(p),p.flags|=1,fa(h,p,_,L),p.child)}function B5(h,p,_,M,P){if(Nh(p),p.stateNode===null){var L=mm,J=_.contextType;typeof J=="object"&&J!==null&&(L=ha(J)),L=new _(M,L),p.memoizedState=L.state!==null&&L.state!==void 0?L.state:null,L.updater=wE,p.stateNode=L,L._reactInternals=p,L=p.stateNode,L.props=M,L.state=p.memoizedState,L.refs={},QM(p),J=_.contextType,L.context=typeof J=="object"&&J!==null?ha(J):mm,L.state=p.memoizedState,J=_.getDerivedStateFromProps,typeof J=="function"&&(_E(p,_,J,M),L.state=p.memoizedState),typeof _.getDerivedStateFromProps=="function"||typeof L.getSnapshotBeforeUpdate=="function"||typeof L.UNSAFE_componentWillMount!="function"&&typeof L.componentWillMount!="function"||(J=L.state,typeof L.componentWillMount=="function"&&L.componentWillMount(),typeof L.UNSAFE_componentWillMount=="function"&&L.UNSAFE_componentWillMount(),J!==L.state&&wE.enqueueReplaceState(L,L.state,null),Tx(p,M,L,P),Ex(),L.state=p.memoizedState),typeof L.componentDidMount=="function"&&(p.flags|=4194308),M=!0}else if(h===null){L=p.stateNode;var xe=p.memoizedProps,Ie=kh(_,xe);L.props=Ie;var rt=L.context,bt=_.contextType;J=mm,typeof bt=="object"&&bt!==null&&(J=ha(bt));var _t=_.getDerivedStateFromProps;bt=typeof _t=="function"||typeof L.getSnapshotBeforeUpdate=="function",xe=p.pendingProps!==xe,bt||typeof L.UNSAFE_componentWillReceiveProps!="function"&&typeof L.componentWillReceiveProps!="function"||(xe||rt!==J)&&T5(p,L,M,J),ku=!1;var lt=p.memoizedState;L.state=lt,Tx(p,M,L,P),Ex(),rt=p.memoizedState,xe||lt!==rt||ku?(typeof _t=="function"&&(_E(p,_,_t,M),rt=p.memoizedState),(Ie=ku||E5(p,_,Ie,M,lt,rt,J))?(bt||typeof L.UNSAFE_componentWillMount!="function"&&typeof L.componentWillMount!="function"||(typeof L.componentWillMount=="function"&&L.componentWillMount(),typeof L.UNSAFE_componentWillMount=="function"&&L.UNSAFE_componentWillMount()),typeof L.componentDidMount=="function"&&(p.flags|=4194308)):(typeof L.componentDidMount=="function"&&(p.flags|=4194308),p.memoizedProps=M,p.memoizedState=rt),L.props=M,L.state=rt,L.context=J,M=Ie):(typeof L.componentDidMount=="function"&&(p.flags|=4194308),M=!1)}else{L=p.stateNode,ZM(h,p),J=p.memoizedProps,bt=kh(_,J),L.props=bt,_t=p.pendingProps,lt=L.context,rt=_.contextType,Ie=mm,typeof rt=="object"&&rt!==null&&(Ie=ha(rt)),xe=_.getDerivedStateFromProps,(rt=typeof xe=="function"||typeof L.getSnapshotBeforeUpdate=="function")||typeof L.UNSAFE_componentWillReceiveProps!="function"&&typeof L.componentWillReceiveProps!="function"||(J!==_t||lt!==Ie)&&T5(p,L,M,Ie),ku=!1,lt=p.memoizedState,L.state=lt,Tx(p,M,L,P),Ex();var mt=p.memoizedState;J!==_t||lt!==mt||ku||h!==null&&h.dependencies!==null&&av(h.dependencies)?(typeof xe=="function"&&(_E(p,_,xe,M),mt=p.memoizedState),(bt=ku||E5(p,_,bt,M,lt,mt,Ie)||h!==null&&h.dependencies!==null&&av(h.dependencies))?(rt||typeof L.UNSAFE_componentWillUpdate!="function"&&typeof L.componentWillUpdate!="function"||(typeof L.componentWillUpdate=="function"&&L.componentWillUpdate(M,mt,Ie),typeof L.UNSAFE_componentWillUpdate=="function"&&L.UNSAFE_componentWillUpdate(M,mt,Ie)),typeof L.componentDidUpdate=="function"&&(p.flags|=4),typeof L.getSnapshotBeforeUpdate=="function"&&(p.flags|=1024)):(typeof L.componentDidUpdate!="function"||J===h.memoizedProps&<===h.memoizedState||(p.flags|=4),typeof L.getSnapshotBeforeUpdate!="function"||J===h.memoizedProps&<===h.memoizedState||(p.flags|=1024),p.memoizedProps=M,p.memoizedState=mt),L.props=M,L.state=mt,L.context=Ie,M=bt):(typeof L.componentDidUpdate!="function"||J===h.memoizedProps&<===h.memoizedState||(p.flags|=4),typeof L.getSnapshotBeforeUpdate!="function"||J===h.memoizedProps&<===h.memoizedState||(p.flags|=1024),M=!1)}return L=M,Nv(h,p),M=(p.flags&128)!==0,L||M?(L=p.stateNode,_=M&&typeof _.getDerivedStateFromError!="function"?null:L.render(),p.flags|=1,h!==null&&M?(p.child=Ah(p,h.child,null,P),p.child=Ah(p,null,_,P)):fa(h,p,_,P),p.memoizedState=L.state,h=p.child):h=dc(h,p,P),h}function $5(h,p,_,M){return wh(),p.flags|=256,fa(h,p,_,M),p.child}var EE={dehydrated:null,treeContext:null,retryLane:0,hydrationErrors:null};function TE(h){return{baseLanes:h,cachePool:jI()}}function AE(h,p,_){return h=h!==null?h.childLanes&~_:0,p&&(h|=Os),h}function H5(h,p,_){var M=p.pendingProps,P=!1,L=(p.flags&128)!==0,J;if((J=L)||(J=h!==null&&h.memoizedState===null?!1:(ci.current&2)!==0),J&&(P=!0,p.flags&=-129),J=(p.flags&32)!==0,p.flags&=-33,h===null){if($n){if(P?Ru(p):Du(),(h=jr)?(h=QF(h,so),h=h!==null&&h.data!=="&"?h:null,h!==null&&(p.memoizedState={dehydrated:h,treeContext:Eu!==null?{id:gl,overflow:xl}:null,retryLane:536870912,hydrationErrors:null},_=wI(h),_.return=p,p.child=_,da=p,jr=null)):h=null,h===null)throw Au(p);return uT(h)?p.lanes=32:p.lanes=536870912,null}var xe=M.children;return M=M.fallback,P?(Du(),P=p.mode,xe=Mv({mode:"hidden",children:xe},P),M=_h(M,P,_,null),xe.return=p,M.return=p,xe.sibling=M,p.child=xe,M=p.child,M.memoizedState=TE(_),M.childLanes=AE(h,J,_),p.memoizedState=EE,Px(null,M)):(Ru(p),CE(p,xe))}var Ie=h.memoizedState;if(Ie!==null&&(xe=Ie.dehydrated,xe!==null)){if(L)p.flags&256?(Ru(p),p.flags&=-257,p=kE(h,p,_)):p.memoizedState!==null?(Du(),p.child=h.child,p.flags|=128,p=null):(Du(),xe=M.fallback,P=p.mode,M=Mv({mode:"visible",children:M.children},P),xe=_h(xe,P,_,null),xe.flags|=2,M.return=p,xe.return=p,M.sibling=xe,p.child=M,Ah(p,h.child,null,_),M=p.child,M.memoizedState=TE(_),M.childLanes=AE(h,J,_),p.memoizedState=EE,p=Px(null,M));else if(Ru(p),uT(xe)){if(J=xe.nextSibling&&xe.nextSibling.dataset,J)var rt=J.dgst;J=rt,M=Error(r(419)),M.stack="",M.digest=J,vx({value:M,source:null,stack:null}),p=kE(h,p,_)}else if(xi||bm(h,p,_,!1),J=(_&h.childLanes)!==0,xi||J){if(J=Sr,J!==null&&(M=Vn(J,_),M!==0&&M!==Ie.retryLane))throw Ie.retryLane=M,vh(h,M),hs(J,h,M),NE;cT(xe)||Dv(),p=kE(h,p,_)}else cT(xe)?(p.flags|=192,p.child=h.child,p=null):(h=Ie.treeContext,jr=lo(xe.nextSibling),da=p,$n=!0,Tu=null,so=!1,h!==null&&MI(p,h),p=CE(p,M.children),p.flags|=4096);return p}return P?(Du(),xe=M.fallback,P=p.mode,Ie=h.child,rt=Ie.sibling,M=ac(Ie,{mode:"hidden",children:M.children}),M.subtreeFlags=Ie.subtreeFlags&65011712,rt!==null?xe=ac(rt,xe):(xe=_h(xe,P,_,null),xe.flags|=2),xe.return=p,M.return=p,M.sibling=xe,p.child=M,Px(null,M),M=p.child,xe=h.child.memoizedState,xe===null?xe=TE(_):(P=xe.cachePool,P!==null?(Ie=pi._currentValue,P=P.parent!==Ie?{parent:Ie,pool:Ie}:P):P=jI(),xe={baseLanes:xe.baseLanes|_,cachePool:P}),M.memoizedState=xe,M.childLanes=AE(h,J,_),p.memoizedState=EE,Px(h.child,M)):(Ru(p),_=h.child,h=_.sibling,_=ac(_,{mode:"visible",children:M.children}),_.return=p,_.sibling=null,h!==null&&(J=p.deletions,J===null?(p.deletions=[h],p.flags|=16):J.push(h)),p.child=_,p.memoizedState=null,_)}function CE(h,p){return p=Mv({mode:"visible",children:p},h.mode),p.return=h,h.child=p}function Mv(h,p){return h=js(22,h,null,p),h.lanes=0,h}function kE(h,p,_){return Ah(p,h.child,null,_),h=CE(p,p.pendingProps.children),h.flags|=2,p.memoizedState=null,h}function V5(h,p,_){h.lanes|=p;var M=h.alternate;M!==null&&(M.lanes|=p),GM(h.return,p,_)}function jE(h,p,_,M,P,L){var J=h.memoizedState;J===null?h.memoizedState={isBackwards:p,rendering:null,renderingStartTime:0,last:M,tail:_,tailMode:P,treeForkCount:L}:(J.isBackwards=p,J.rendering=null,J.renderingStartTime=0,J.last=M,J.tail=_,J.tailMode=P,J.treeForkCount=L)}function G5(h,p,_){var M=p.pendingProps,P=M.revealOrder,L=M.tail;M=M.children;var J=ci.current,xe=(J&2)!==0;if(xe?(J=J&1|2,p.flags|=128):J&=1,ae(ci,J),fa(h,p,M,_),M=$n?yx:0,!xe&&h!==null&&(h.flags&128)!==0)e:for(h=p.child;h!==null;){if(h.tag===13)h.memoizedState!==null&&V5(h,_,p);else if(h.tag===19)V5(h,_,p);else if(h.child!==null){h.child.return=h,h=h.child;continue}if(h===p)break e;for(;h.sibling===null;){if(h.return===null||h.return===p)break e;h=h.return}h.sibling.return=h.return,h=h.sibling}switch(P){case"forwards":for(_=p.child,P=null;_!==null;)h=_.alternate,h!==null&&fv(h)===null&&(P=_),_=_.sibling;_=P,_===null?(P=p.child,p.child=null):(P=_.sibling,_.sibling=null),jE(p,!1,P,_,L,M);break;case"backwards":case"unstable_legacy-backwards":for(_=null,P=p.child,p.child=null;P!==null;){if(h=P.alternate,h!==null&&fv(h)===null){p.child=P;break}h=P.sibling,P.sibling=_,_=P,P=h}jE(p,!0,_,null,L,M);break;case"together":jE(p,!1,null,null,void 0,M);break;default:p.memoizedState=null}return p.child}function dc(h,p,_){if(h!==null&&(p.dependencies=h.dependencies),Iu|=p.lanes,(_&p.childLanes)===0)if(h!==null){if(bm(h,p,_,!1),(_&p.childLanes)===0)return null}else return null;if(h!==null&&p.child!==h.child)throw Error(r(153));if(p.child!==null){for(h=p.child,_=ac(h,h.pendingProps),p.child=_,_.return=p;h.sibling!==null;)h=h.sibling,_=_.sibling=ac(h,h.pendingProps),_.return=p;_.sibling=null}return p.child}function PE(h,p){return(h.lanes&p)!==0?!0:(h=h.dependencies,!!(h!==null&&av(h)))}function kie(h,p,_){switch(p.tag){case 3:Te(p,p.stateNode.containerInfo),Cu(p,pi,h.memoizedState.cache),wh();break;case 27:case 5:Me(p);break;case 4:Te(p,p.stateNode.containerInfo);break;case 10:Cu(p,p.type,p.memoizedProps.value);break;case 31:if(p.memoizedState!==null)return p.flags|=128,rE(p),null;break;case 13:var M=p.memoizedState;if(M!==null)return M.dehydrated!==null?(Ru(p),p.flags|=128,null):(_&p.child.childLanes)!==0?H5(h,p,_):(Ru(p),h=dc(h,p,_),h!==null?h.sibling:null);Ru(p);break;case 19:var P=(h.flags&128)!==0;if(M=(_&p.childLanes)!==0,M||(bm(h,p,_,!1),M=(_&p.childLanes)!==0),P){if(M)return G5(h,p,_);p.flags|=128}if(P=p.memoizedState,P!==null&&(P.rendering=null,P.tail=null,P.lastEffect=null),ae(ci,ci.current),M)break;return null;case 22:return p.lanes=0,I5(h,p,_,p.pendingProps);case 24:Cu(p,pi,h.memoizedState.cache)}return dc(h,p,_)}function W5(h,p,_){if(h!==null)if(h.memoizedProps!==p.pendingProps)xi=!0;else{if(!PE(h,_)&&(p.flags&128)===0)return xi=!1,kie(h,p,_);xi=(h.flags&131072)!==0}else xi=!1,$n&&(p.flags&1048576)!==0&&NI(p,yx,p.index);switch(p.lanes=0,p.tag){case 16:e:{var M=p.pendingProps;if(h=Eh(p.elementType),p.type=h,typeof h=="function")IM(h)?(M=kh(h,M),p.tag=1,p=B5(null,p,h,M,_)):(p.tag=0,p=ME(null,p,h,M,_));else{if(h!=null){var P=h.$$typeof;if(P===E){p.tag=11,p=D5(null,p,h,M,_);break e}else if(P===A){p.tag=14,p=O5(null,p,h,M,_);break e}}throw p=j(h)||h,Error(r(306,p,""))}}return p;case 0:return ME(h,p,p.type,p.pendingProps,_);case 1:return M=p.type,P=kh(M,p.pendingProps),B5(h,p,M,P,_);case 3:e:{if(Te(p,p.stateNode.containerInfo),h===null)throw Error(r(387));M=p.pendingProps;var L=p.memoizedState;P=L.element,ZM(h,p),Tx(p,M,null,_);var J=p.memoizedState;if(M=J.cache,Cu(p,pi,M),M!==L.cache&&WM(p,[pi],_,!0),Ex(),M=J.element,L.isDehydrated)if(L={element:M,isDehydrated:!1,cache:J.cache},p.updateQueue.baseState=L,p.memoizedState=L,p.flags&256){p=$5(h,p,M,_);break e}else if(M!==P){P=ro(Error(r(424)),p),vx(P),p=$5(h,p,M,_);break e}else{switch(h=p.stateNode.containerInfo,h.nodeType){case 9:h=h.body;break;default:h=h.nodeName==="HTML"?h.ownerDocument.body:h}for(jr=lo(h.firstChild),da=p,$n=!0,Tu=null,so=!0,_=II(p,null,M,_),p.child=_;_;)_.flags=_.flags&-3|4096,_=_.sibling}else{if(wh(),M===P){p=dc(h,p,_);break e}fa(h,p,M,_)}p=p.child}return p;case 26:return Nv(h,p),h===null?(_=rU(p.type,null,p.pendingProps,null))?p.memoizedState=_:$n||(_=p.type,h=p.pendingProps,M=Bv(pe.current).createElement(_),M[st]=p,M[Yt]=h,ma(M,_,h),Be(M),p.stateNode=M):p.memoizedState=rU(p.type,h.memoizedProps,p.pendingProps,h.memoizedState),null;case 27:return Me(p),h===null&&$n&&(M=p.stateNode=eU(p.type,p.pendingProps,pe.current),da=p,so=!0,P=jr,$u(p.type)?(dT=P,jr=lo(M.firstChild)):jr=P),fa(h,p,p.pendingProps.children,_),Nv(h,p),h===null&&(p.flags|=4194304),p.child;case 5:return h===null&&$n&&((P=M=jr)&&(M=sae(M,p.type,p.pendingProps,so),M!==null?(p.stateNode=M,da=p,jr=lo(M.firstChild),so=!1,P=!0):P=!1),P||Au(p)),Me(p),P=p.type,L=p.pendingProps,J=h!==null?h.memoizedProps:null,M=L.children,sT(P,L)?M=null:J!==null&&sT(P,J)&&(p.flags|=32),p.memoizedState!==null&&(P=aE(h,p,_ie,null,null,_),qx._currentValue=P),Nv(h,p),fa(h,p,M,_),p.child;case 6:return h===null&&$n&&((h=_=jr)&&(_=oae(_,p.pendingProps,so),_!==null?(p.stateNode=_,da=p,jr=null,h=!0):h=!1),h||Au(p)),null;case 13:return H5(h,p,_);case 4:return Te(p,p.stateNode.containerInfo),M=p.pendingProps,h===null?p.child=Ah(p,null,M,_):fa(h,p,M,_),p.child;case 11:return D5(h,p,p.type,p.pendingProps,_);case 7:return fa(h,p,p.pendingProps,_),p.child;case 8:return fa(h,p,p.pendingProps.children,_),p.child;case 12:return fa(h,p,p.pendingProps.children,_),p.child;case 10:return M=p.pendingProps,Cu(p,p.type,M.value),fa(h,p,M.children,_),p.child;case 9:return P=p.type._context,M=p.pendingProps.children,Nh(p),P=ha(P),M=M(P),p.flags|=1,fa(h,p,M,_),p.child;case 14:return O5(h,p,p.type,p.pendingProps,_);case 15:return L5(h,p,p.type,p.pendingProps,_);case 19:return G5(h,p,_);case 31:return Cie(h,p,_);case 22:return I5(h,p,_,p.pendingProps);case 24:return Nh(p),M=ha(pi),h===null?(P=XM(),P===null&&(P=Sr,L=qM(),P.pooledCache=L,L.refCount++,L!==null&&(P.pooledCacheLanes|=_),P=L),p.memoizedState={parent:M,cache:P},QM(p),Cu(p,pi,P)):((h.lanes&_)!==0&&(ZM(h,p),Tx(p,null,null,_),Ex()),P=h.memoizedState,L=p.memoizedState,P.parent!==M?(P={parent:M,cache:M},p.memoizedState=P,p.lanes===0&&(p.memoizedState=p.updateQueue.baseState=P),Cu(p,pi,M)):(M=L.cache,Cu(p,pi,M),M!==P.cache&&WM(p,[pi],_,!0))),fa(h,p,p.pendingProps.children,_),p.child;case 29:throw p.pendingProps}throw Error(r(156,p.tag))}function hc(h){h.flags|=4}function RE(h,p,_,M,P){if((p=(h.mode&32)!==0)&&(p=!1),p){if(h.flags|=16777216,(P&335544128)===P)if(h.stateNode.complete)h.flags|=8192;else if(yF())h.flags|=8192;else throw Th=cv,YM}else h.flags&=-16777217}function q5(h,p){if(p.type!=="stylesheet"||(p.state.loading&4)!==0)h.flags&=-16777217;else if(h.flags|=16777216,!lU(p))if(yF())h.flags|=8192;else throw Th=cv,YM}function Ev(h,p){p!==null&&(h.flags|=4),h.flags&16384&&(p=h.tag!==22?Rt():536870912,h.lanes|=p,km|=p)}function Rx(h,p){if(!$n)switch(h.tailMode){case"hidden":p=h.tail;for(var _=null;p!==null;)p.alternate!==null&&(_=p),p=p.sibling;_===null?h.tail=null:_.sibling=null;break;case"collapsed":_=h.tail;for(var M=null;_!==null;)_.alternate!==null&&(M=_),_=_.sibling;M===null?p||h.tail===null?h.tail=null:h.tail.sibling=null:M.sibling=null}}function Pr(h){var p=h.alternate!==null&&h.alternate.child===h.child,_=0,M=0;if(p)for(var P=h.child;P!==null;)_|=P.lanes|P.childLanes,M|=P.subtreeFlags&65011712,M|=P.flags&65011712,P.return=h,P=P.sibling;else for(P=h.child;P!==null;)_|=P.lanes|P.childLanes,M|=P.subtreeFlags,M|=P.flags,P.return=h,P=P.sibling;return h.subtreeFlags|=M,h.childLanes=_,p}function jie(h,p,_){var M=p.pendingProps;switch(BM(p),p.tag){case 16:case 15:case 0:case 11:case 7:case 8:case 12:case 9:case 14:return Pr(p),null;case 1:return Pr(p),null;case 3:return _=p.stateNode,M=null,h!==null&&(M=h.memoizedState.cache),p.memoizedState.cache!==M&&(p.flags|=2048),lc(pi),ge(),_.pendingContext&&(_.context=_.pendingContext,_.pendingContext=null),(h===null||h.child===null)&&(xm(p)?hc(p):h===null||h.memoizedState.isDehydrated&&(p.flags&256)===0||(p.flags|=1024,HM())),Pr(p),null;case 26:var P=p.type,L=p.memoizedState;return h===null?(hc(p),L!==null?(Pr(p),q5(p,L)):(Pr(p),RE(p,P,null,M,_))):L?L!==h.memoizedState?(hc(p),Pr(p),q5(p,L)):(Pr(p),p.flags&=-16777217):(h=h.memoizedProps,h!==M&&hc(p),Pr(p),RE(p,P,h,M,_)),null;case 27:if(we(p),_=pe.current,P=p.type,h!==null&&p.stateNode!=null)h.memoizedProps!==M&&hc(p);else{if(!M){if(p.stateNode===null)throw Error(r(166));return Pr(p),null}h=se.current,xm(p)?EI(p):(h=eU(P,M,_),p.stateNode=h,hc(p))}return Pr(p),null;case 5:if(we(p),P=p.type,h!==null&&p.stateNode!=null)h.memoizedProps!==M&&hc(p);else{if(!M){if(p.stateNode===null)throw Error(r(166));return Pr(p),null}if(L=se.current,xm(p))EI(p);else{var J=Bv(pe.current);switch(L){case 1:L=J.createElementNS("http://www.w3.org/2000/svg",P);break;case 2:L=J.createElementNS("http://www.w3.org/1998/Math/MathML",P);break;default:switch(P){case"svg":L=J.createElementNS("http://www.w3.org/2000/svg",P);break;case"math":L=J.createElementNS("http://www.w3.org/1998/Math/MathML",P);break;case"script":L=J.createElement("div"),L.innerHTML=" - + +